Two endpoints accept an idempotency key:
  • POST /scans
  • POST /findings/bulk
Both do work that must not happen twice. A CI job that suppresses 400 findings and loses its connection while reading the response has no way to know whether the work happened — and it will retry, because that is what CI does.

Supplying a key

As a header:
Or in the body, as idempotency_key, for clients that cannot easily set headers:
The key is a string of at most 200 characters; longer is a 400. It is scoped to your organisation, so two customers using the same key never collide. Choose a key that identifies the work, not the attempt. A build identifier, a job identifier, or a deterministic hash of the request. A fresh UUID per attempt makes every retry a new operation, which is precisely what you were trying to avoid.

The key is reserved before any work

The obvious implementation looks the key up and executes if it is absent. Two concurrent retries — the normal case, because that is exactly what a timed-out client produces — both find it absent, and both execute. So the key is reserved first, with an insert the database serialises. Only the caller who wins the insert executes. Everyone else reads the row that already exists and is answered from it:
An operation that fails releases its reservation, so the obvious retry — immediately — is not refused as “in flight”. That is safe precisely because the operation did not complete.

Replays

A completed request replays its stored response verbatim, with a header:
Verbatim matters. A retry sees what the first attempt returned, not a fresh evaluation of the same request against a world the first attempt already changed. Re-running a bulk suppression would report every item as already_applied and zero as applied — technically accurate, and useless to a caller trying to log what its own operation did. A client that wants to distinguish “I did this” from “this had already been done” reads the header. A client that does not care can ignore it entirely, which is the point: the response is the same shape either way. Records are retained for 30 days, then swept. That is long enough for any realistic retry — a pipeline a human re-runs the next morning, a queue drained after a weekend outage — and short enough that the table does not become an unbounded log of every automated request ever made. After 30 days the key is available again and will execute new work. If that matters to you, put a date in the key.

Reusing a key for different work is a conflict

Sending a different request under a key that has already completed is a 409. It is never a replay.
Returning the first response would tell the caller that work succeeded which was never attempted. A pipeline that reused build-4471 for a scan of a different image would receive the first image’s scan, log it as its own, and gate a release on a result that describes something else entirely. The comparison is made over a fingerprint of everything that changes what the request does — the image and scanner list for a scan; the action, finding identifiers and suppression details for a bulk update. It is order-insensitive, so a client that reserialises a map between attempts is not punished for it.

In-flight requests

A key reserved by a request that is still running returns 409 as well, with a different message. Retry shortly — the first attempt is very likely about to succeed and store a response you can then replay. If the process holding the reservation dies, the reservation goes stale and the next retry reclaims it. Nothing is stuck permanently.

Where the record lives

In PostgreSQL, alongside everything else, and not in Redis. Redis is the tempting home and the wrong one, for the same reason it is the wrong home for a job queue: a record whose only copy is in a cache is not a record. A key that evaporates on eviction turns “safe to retry” into “safe to retry most of the time”, which is unsafe with extra steps.

One deliberate asymmetry

If the write that stores the response fails after the work itself succeeded, the response is returned to the caller anyway and the failure is logged. Reporting an error there would tell the caller the work did not happen. Their retry would do it again — which is the exact outcome the key exists to prevent. Losing the ability to replay is a smaller harm than performing the operation twice.

Endpoints that need no key

Most of the API is already safe to retry without one. GET is safe by definition. PUT /assets/{id}/schedule is idempotent because a schedule is a singleton per asset, enforced by a unique index even under a race. PATCH /findings/{id} requesting the state a finding is already in succeeds and writes nothing — a retried request, a double-clicked button and a stale tab all produce exactly that. DELETE /api-keys/{id} is idempotent by design, because a revocation retried during an incident must not fail. POST /scans/{id}/verdict has its own mechanism: it is idempotent per (scan, policy version), enforced by a unique constraint. A retry returns 200 with the same decision rather than recording a second one, and a first request returns 201. No key is required, and the endpoint accepts none.

Cross-references