Idempotency-Key header. Send
one, and a repeat gets the same response back — the same status code, the
same body — instead of running again.
201
201 with created: true:
200 with created: false — correct,
but useless to the code that made it. A program that provisions a workspace when
created is true cannot tell “somebody else already did this” from “my own retry
went through”.
Choosing a key
Anything opaque, up to 255 characters. A UUID per attempt is the usual answer; your own identifier for the operation (order_8421-refund) works too and has the
advantage that a process restarting from scratch regenerates the same one.
The key is scoped to your API key and the route. Two tenants sending
Idempotency-Key: 1 never collide, a live key never replays what a test key
answered, and the same key on a different endpoint is a different operation.
Keys are remembered for 24 hours. Every retry that will ever carry one
happens inside that window — a client’s backoff is seconds, a queue’s redelivery
is minutes, a person rerunning a failed job is hours. A day later you are not
retrying, you are calling again, and you should send a new key.
The three answers a repeat can get
Replayed — the same response
Replayed — the same response
The first request finished. You get its status and its body, plus
Idempotent-Replay: true. The handler does not run.409 — still running
409 — still running
Another request with this key is in flight. Exactly one of them reaches the
handler, by construction, so this is not a race you lost — it is the race
being closed. Wait for
Retry-After and try again; you will get the replay.409
422 — the key was used for something else
422 — the key was used for something else
The key is on record with a different request body or query string. Replaying
the first response here would tell you a call succeeded when it never
happened, so it is refused instead.This almost always means a key got reused by accident — a constant left in
the code, or a retry that rebuilt its payload from changed state. Generate a
new key per operation, not per process.
422
A failure is not a result
A request that answered5xx, or that was rate limited before it reached the
handler, left nothing behind. Its key goes back on the shelf: retry with the
same key and the request runs.
A request that answered 201 did create something, and its key is spent. So is
one that answered 400 — the same body will produce the same 400, and
replaying it costs you nothing.
That asymmetry is the whole rule, and it is what makes the naive retry loop
correct:
1
Generate one key for the operation
Before the first attempt, not inside it.
2
Send it on every attempt
Same key, same body.
3
Treat 409 as 'not yet'
Back off and retry. Treat
422 as a bug in your own code — a new key will
not fix a reused one, it will hide it.Where it applies
Only the secret-key surface,uk_sk_…. Reads ignore the header: replaying a
GET would hand back a stale answer to a question you asked precisely because
you wanted a fresh one.
The surfaces called from a browser — /v1/boot, /v1/contact-auth/*,
/v1/contact/* — do not take the header. They are driven by a person, and the
flows that could double up are already single-use by construction: a magic link,
an email code and a social callback are each spent by the first request that
redeems them.
POST /v1/contacts resolves to the same person whether you call it once or ten
times — identifying values never re-point to a second contact. The key is what
makes the answer stable too, which is what your code actually branches on.