Idempotency-Key: retrying POST /orders without creating a second order Intermediate
Estimated reading time: 10 min read
The problem
Section titled “The problem”A shopper taps Pay in a mobile app. The app sends POST /orders for one pair of shoes, qty: 1. The API creates order 1 and charges the card, but the 201 response is lost on a flaky 4G link.
After its 10-second timeout (illustrative), the app does not know whether the order exists.
If the app sends the same POST again, the API sees a brand-new request and creates order 2. If the app gives up, the shopper may tap again and get the same result. Neither choice is safe.
The question: how does an API let the client retry a POST and still produce exactly one order? Webhooks and consumers have their own dedupe articles; this one covers the HTTP API layer.
Which design? (summary first)
Section titled “Which design? (summary first)”| Situation | Choose | Condition |
|---|---|---|
| Client creates an order or payment and may retry after a timeout | Idempotency-Key + stored response |
Key scoped per user + endpoint, fingerprint, TTL above the retry window |
| The client can choose the resource URI | PUT /orders/{clientId} |
PUT is already idempotent |
| The work is slow (calls a payment gateway) | Two-phase claim, 409 for a concurrent duplicate |
A lease to recover keys stuck in_progress |
| The work is short and only touches PostgreSQL | Claim + effect in one transaction | The duplicate waits on the unique index instead of getting 409 |
Core concepts
Section titled “Core concepts”Why POST is not idempotent
Section titled “Why POST is not idempotent”RFC 9110 §9.2.2 calls a method idempotent when several identical requests have the same intended effect as one. PUT, DELETE and safe methods (GET, HEAD, OPTIONS, TRACE) are idempotent; POST is not, and neither is PATCH (RFC 5789).
Hence a client SHOULD NOT automatically retry a non-idempotent request unless it can know the request is idempotent or was never applied. Idempotency-Key provides that knowledge: a contract the server publishes per endpoint.
The Idempotency-Key header
Section titled “The Idempotency-Key header”The header is an IETF httpapi working group Internet-Draft, not an RFC: draft-ietf-httpapi-idempotency-key-header-08 (22 October 2026). Stripe’s API already uses the same header name. The draft says:
| Topic | Draft-08 text (paraphrased) |
|---|---|
| Syntax | Structured Field Item, value must be a String: Idempotency-Key: "6f1c2a8e-..." |
| Key value | Unique, must not be reused with a different payload; UUID or similar random value recommended |
| Fingerprint | Optional: the server may combine the key with a hash of the payload |
| Expiry | The resource should define an expiry policy and publish it in its documentation |
| Missing key | 400 when the documented operation requires the header |
| Same key, different payload | 422 |
| Duplicate while the first is still processing | 409 |
Stripe sends the value unquoted and allows up to 255 characters. The draft may still change; follow the server’s documentation.
How it works
Section titled “How it works”POST + K1The user taps Pay once. The app creates key K1 for this intent and sends POST /orders.
State after each step:
- Request: the app holds key
K1for this tap; the database is empty. - Claim: row
(7, POST /orders, K1, in_progress); the primary key blocks a second row. - Effect: order 1, outbox event and
response_code = 201commit together. - Response lost: server state is final; only the client is uncertain.
- Retry: the claim returns 0 rows; the API reads the existing row.
- Replay: fingerprint matches, status
completed: the stored201goes back. Still one order.
Atomic claim on PostgreSQL 17
Section titled “Atomic claim on PostgreSQL 17”The scope is (user_id, endpoint, key), so two users with the same UUID never collide. The fingerprint is a SHA-256 of method, path and canonical JSON body. The claim is one statement; SELECT then INSERT races, as upsert with ON CONFLICT shows.
CREATE TABLE idempotency_keys ( user_id bigint NOT NULL, endpoint text NOT NULL, idem_key text NOT NULL, request_hash text NOT NULL, status text NOT NULL CHECK (status IN ('in_progress', 'completed')), response_code int, response_body jsonb, locked_until timestamptz, -- lease for in_progress rows expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours', PRIMARY KEY (user_id, endpoint, idem_key));
INSERT INTO idempotency_keys (user_id, endpoint, idem_key, request_hash, status, locked_until)VALUES ($1, $2, $3, $4, 'in_progress', now() + interval '30 seconds')ON CONFLICT (user_id, endpoint, idem_key) DO NOTHINGRETURNING idem_key;One returned row means this request owns the key. Zero rows means another request got there first; the handler reads the row and decides:
| Existing row | Response | Why |
|---|---|---|
Different request_hash |
422 |
Key reused for another intent (draft-08) |
Same hash, in_progress |
409 (or wait) |
The first attempt has not finished |
Same hash, completed |
Stored status + body | The retry gets what the first attempt got |
Concurrent duplicate: 409 or wait
Section titled “Concurrent duplicate: 409 or wait”A retry can arrive while the first request is still calling the payment gateway. What happens depends on when the claim commits.
A claims K2 in a short transaction and commits status in_progress, then calls the gateway.
- Two-phase (diagram): the claim commits alone, so B sees
in_progressat once and gets409. No lock is held during the gateway call. - One transaction: the claim is uncommitted until the effect commits. B’s
INSERT ... ON CONFLICTwaits on the unique index (wait_event = transactionidin the demo), then returns 0 rows. If A rolls back, B’s insert succeeds.
Waiting suits short database-only work. For slow external calls it holds a transaction open for the whole call; prefer two-phase and 409.
Key lifecycle
Section titled “Key lifecycle”Loading diagram…
Operational details
Section titled “Operational details”- What to store: status code, body and headers the client needs (
Location).jsonbreorders keys (see demo line 2); usetextif byte-identical replay matters. - Which outcomes: store results once execution starts. Validation
400s happen before the claim, so the client can fix and resend. Stripe v1 follows this rule and also replays stored500s. - TTL: above the longest client retry window, offline queues included. Stripe v1 may prune keys after 24 hours; a pruned key starts a new request. Purge by
expires_atwith an index. - Stuck
in_progress: a crash after the claim leaves the row untillocked_until; a retry may then take it over withUPDATE ... WHERE locked_until < now(). - External side effects: a takeover can call the gateway again, so pass it a derived key. Events go through the transactional outbox in the response’s transaction.
- Errors:
400,409,422as Problem Details with a stabletype, like draft-08’s examples.
Real-world example
Section titled “Real-world example”The demo runs the handler against PostgreSQL 17 with two connections:
docker compose -f examples/docker-compose.yml up -d postgresnpx --prefix examples ts-node examples/src/idempotency-key/demo.tsOutput (PostgreSQL 17.11)
1. first POST -> 201 {"orderId":1,"sku":"SHOE-42","qty":1,"status":"created"}2. retry, same key + same body -> 201 (replayed) {"qty":1,"sku":"SHOE-42","status":"created","orderId":1}3. same key, qty changed to 2 -> 422 {"type":"https://api.example.org/problems/idempotency-key-reused",...}4. same key, another user -> 201 {"orderId":2,...}6b. retry while A in progress -> 409 {"type":"https://api.example.org/problems/request-in-progress",...}6a. A finishes -> 201 {"orderId":3,...}6c. retry after A finished -> 201 (replayed) {...,"orderId":3}7. purged expired keys: 17b. late retry after expiry -> 201 {"orderId":4,...}orders now: 48. B while A uncommitted: { wait_event_type: 'Lock', wait_event: 'transactionid' }8. B after A COMMIT: rowCount = 0 (waited ~300 ms)Lines 2 and 6c replay; line 4 shows the per-user scope. Line 7b is the TTL trap: a retry after the purge creates a second order. Output is abridged with .... The demo lacks lease takeover, a real gateway call and request timeouts.
Trade-offs
Section titled “Trade-offs”What Idempotency-Key gives you
- Clients can retry
POSTafter timeouts without duplicate orders or charges. - The retry gets the original result, including the created ID.
422exposes client bugs that reuse keys.
Costs and boundaries
- An extra write per request; the guarantee ends at the TTL.
- Two-phase needs a lease and takeover logic for crashed requests.
- External calls still need their own idempotency (gateway key, outbox).
Production checklist
Section titled “Production checklist”- Document which endpoints require
Idempotency-Key, the key format, the TTL and the error types. - Scope the unique key by caller and endpoint:
PRIMARY KEY (user_id, endpoint, idem_key). - Claim with
INSERT ... ON CONFLICT DO NOTHING RETURNING; neverSELECTthenINSERT. - Store a fingerprint of method, path and canonical body; return
422on mismatch. - Commit the business effect, the outbox row and the stored response in one transaction.
- Choose
409or wait for concurrent duplicates; with409, add a lease onin_progressrows. - Set the TTL above the client’s longest retry window and purge expired keys in batches.
- Alert on
409and422rates per client version.
Common mistakes
Section titled “Common mistakes”Most client bugs come from generating the key at the wrong moment.
One key per user intent, reused on retry
Common
// New key on every attempt: the server sees two different requestsawait retry(() => api.post('/orders', cart, { headers: { 'Idempotency-Key': crypto.randomUUID() } }));Prefer
const key = crypto.randomUUID(); // created once, when the user taps Payawait retry(() => api.post('/orders', cart, { headers: { 'Idempotency-Key': key } }));Why
A key generated inside the retry loop deduplicates nothing. Keep it with the intent, and persist it if retries survive an app restart.
| Misunderstanding | Consequence | Do instead |
|---|---|---|
“A unique constraint on orders is enough” |
Retry gets an error, not the order ID | Store and replay the first response |
| “Keys can live in Redis only” | Key and order commit separately; a crash between them breaks dedupe | Key and effect in one database transaction |
| “A 1-hour TTL is plenty” | Offline queues retry later and duplicate | TTL above the longest retry window |
Self-check
Section titled “Self-check”- Two requests with the same key arrive 50 ms apart. What does each get in the two-phase design, and in the single-transaction design?
- Why must the validation
400not be stored under the key? - The process crashes after the gateway charged the card but before
COMMIT. What prevents a second charge on takeover?
Summary
Section titled “Summary”References
Section titled “References”- draft-ietf-httpapi-idempotency-key-header-08 — IETF: syntax, fingerprint,
400/409/422cases. - RFC 9110 §9.2.2 Idempotent Methods: which methods are idempotent and when clients may retry.
- PostgreSQL 17 — INSERT:
ON CONFLICT DO NOTHINGandRETURNING. - Stripe — Idempotent requests: key length, 24-hour pruning, which results are saved.
- RFC 9457 — Problem Details: the error body format for
409and422.