POST /scansPOST /findings/bulk
Supplying a key
As a header:idempotency_key, for clients that cannot easily set
headers:
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:Replays
A completed request replays its stored response verbatim, with a header: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 a409. It is never a replay.
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 returns409 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
- Errors and limits — the full limits table.
- Gate a pipeline on a policy — the flow these keys were built for.

