Idempotency
Safe retries for any write with the Idempotency-Key header: how keys are stored and replayed, and what happens on conflicting or concurrent requests.
Networks fail after the server has done the work. A client sends POST /payments, the connection drops, and the client has no way to know whether the card was charged. Retry, and the customer may be charged twice. Don't, and the order may never be paid. Both are wrong, and the client can't tell which situation it's in.
An idempotency key removes the dilemma. The client attaches a key to the operation, and the server promises that the same key always produces the same result, with the side effect happening at most once. Now retrying is always safe, so clients can retry freely, which is exactly what you want them to do.
The Header
Every POST accepts an Idempotency-Key header, as defined by the IETF Idempotency-Key draft.
POST /payments
Idempotency-Key: f47ac10b-58cc-4372-a567-0e02b2c3d479
Content-Type: application/json
{ "amount": 1999, "currency": "USD", "source": "tok_visa" }
- The client generates the key, because only the client knows when two requests are the same operation. A UUID v4 is ideal: random, with no information to leak. Up to 255 characters.
- One key per logical operation, not per HTTP attempt. A key generated fresh on every retry protects nothing. Generate it when the user clicks "Pay", store it with the order, and reuse it until the operation resolves.
- Never put sensitive data in a key. Keys end up in logs and storage. An email or card number in a key is a leak waiting to happen.
- Only
POSTneeds one.GET,PUTandDELETEare idempotent by definition in RFC 9110: repeating them leaves the same state.PATCHisn't by definition, but a JSON Merge Patch that sets absolute values is idempotent in practice. A key sent with any of these is ignored.
What the Server Stores
The first request with a key records:
| Field | Purpose |
|---|---|
| Key, scoped to the API account | Two accounts' keys can't collide, and one account can't probe another's |
| Fingerprint of the request | Method, path and a hash of the body, to detect a key reused for something else |
| Status | in_progress or completed |
| Response | Status code, headers and body, once completed |
| Created at | For expiry |
The key record and the side effect are written in the same database transaction wherever possible. That's the whole trick: if the payment row commits, so does the key, and if either fails, neither exists. Storing the key in a separate system (a cache, a different database) reopens the gap the key was meant to close, because a crash between the two writes leaves a charge with no record of it.
When the side effect lives somewhere else, like a card network, a single transaction isn't possible. Record the key as in_progress first, call the processor with its own idempotency key derived from ours, then complete the record. A crash in the middle leaves an in_progress key that a recovery job reconciles against the processor.
Behavior
| Request | Result |
|---|---|
| New key | Processed normally, response stored |
| Same key, same request, completed | Stored response replayed, nothing re-executed |
| Same key, same request, still in progress | 409 Conflict: retry shortly with the same key |
| Same key, different request | 400 Bad Request: the key is already bound to another operation |
| Key older than the retention window | Treated as new |
A replayed response is byte-for-byte the original and adds one header, so the client can tell it's a replay:
HTTP/1.1 201 Created
Idempotent-Replayed: true
The mismatch case is a client bug: the same key was attached to two different operations. The IETF draft suggests 422 here, and I return 400 instead, as Stripe does. In these docs 422 means field-level validation failed and comes with an errors object pointing at the fields (Error Responses). A key mismatch has no field to point at. The body itself may be perfectly valid; it's the pairing with the key that's wrong.
What Gets Stored, and Why Errors Count
A result is stored once the endpoint begins executing, whatever the outcome. That includes declines and even 500 errors, which surprises people.
The reason: a 500 from a payment endpoint doesn't mean nothing happened. It means the server doesn't know what happened. The charge may have gone through before the error. If the retry re-executed the request, it could charge the card a second time. Replaying the stored 500 is the honest answer: "this operation's outcome is uncertain, go and check". The client reconciles by fetching the payment or watching webhooks, instead of gambling with a second charge.
Requests rejected before execution aren't stored: failed validation, bad authentication, rate limiting (429), or a 409 for a concurrent request. Nothing happened, so the client fixes the problem and retries with the same key.
Retention
Keys are kept for at least 24 hours, then pruned. Reusing a pruned key starts a new operation.
Why 24 hours: a retry window has to outlast realistic failures. Seconds cover network blips, minutes cover a deploy, and a day covers a client that crashed overnight and retries its queue in the morning. Longer than that and a "retry" is really a new decision someone should make deliberately. The window also bounds storage: keys are one row per write, and keeping them forever means a table that grows with every request the API has ever served. Stripe uses the same 24-hour minimum, and so does its client guidance.
Retry an unresolved operation only within those 24 hours. Past that, stop retrying with either the old key or a new one, and reconcile: fetch the object, or check events.
Idempotency Beyond the API
The header protects the API boundary. The same rule applies at every hop inside the system, because every hop can fail halfway:
- Calls to processors carry their own idempotency key, derived from ours, so our retry to the card network can't double-charge either.
- Queue consumers dedupe by message ID, because every queue worth using delivers at least once.
- Webhook receivers dedupe by event ID. See Webhooks.
Exactly-once delivery doesn't exist across a network. At-least-once delivery plus idempotent handling is how you get exactly-once effects, and that's the only kind that matters.
Client Expectations
- Generate a key before the first attempt, and persist it with the operation so it survives process restarts.
- Reuse that key on every retry, with the exact same body.
- On
409, back off and retry the same key. A new key could start a second operation while the first is still running. - On
400for a key mismatch, you've reused a key for a different operation. Fix the client. - To change the parameters, first confirm the original didn't succeed and isn't still running, then use a new key.
References
Specifications
- IETF: The Idempotency-Key HTTP Header Field (draft), the header,
409for concurrent requests, and published expiry - RFC 9110: HTTP Semantics, which methods are idempotent by definition
Reference
- MDN: Idempotency-Key, header syntax
- MDN: 409 Conflict, concurrent requests with the same key
In practice
- Stripe: Idempotent requests, storing results once execution begins, including
500s, and a 24-hour minimum - Stripe: Advanced error handling, retrying within the window, then reconciling
- Stripe: Designing robust and predictable APIs with idempotency, why keys exist
- Brandur Leach: Implementing Stripe-like Idempotency Keys in Postgres, storing keys in the same transaction as the side effect
- AWS Builders' Library: Making retries safe with idempotent APIs, idempotency across internal hops