Idempotency Keys
How payment APIs survive retries.
Idempotency Keys
POST is the most dangerous method in your API. A timeout, a flaky network, a retry middleware — any of these can turn one user click into two charged credit cards. The fix is a single header: Idempotency-Key.
Why payments need this
GET, PUT, and DELETE are idempotent by HTTP spec. Resend them all you like — the server's state lands in the same place. POST is not. A POST that creates a payment, places an order, or sends an email must assume each call is a distinct intent.
That assumption breaks the moment a network is involved. A 30-second timeout is not "the request didn't happen" — it's "the request might have succeeded, you just don't know." If the client retries blindly, both calls might hit the server. The user pays twice.
Stripe documented the de-facto fix in 2017 and the IETF draft (draft-ietf-httpapi-idempotency-key-header) follows the same shape:
POST /v1/charges HTTP/1.1
Idempotency-Key: 8e6e4c0f-2a8f-4c1f-b3a7-3a8a4a8e1e7c
Authorization: Bearer sk_test_...
Content-Type: application/json
{ "amount": 5000, "currency": "usd", "source": "tok_visa" }
The client picks a UUID. The server stores the response keyed by that UUID. Any later request with the same key gets the same response — bit-identical, including the original status code.
The three questions a server has to answer
- Have I seen this key before? If no, execute and remember.
- Did the body change? If yes, refuse — different operation under the same key is almost always a client bug. Return
422 Unprocessable Entity. - Is the saved response still valid? Most implementations TTL the entry at 24 hours. After that, a retry with the same key executes again as if it were new.
The whole protocol fits on a napkin:
Client Server
│── POST /charges ───────────▶│
│ Idempotency-Key: K │ hash(method, path, body) = F
│ │ GET idemp:K → ∅
│ │ ─── execute ───────────▶ DB
│ │ SET idemp:K = {F, status, body, EX 86400}
│◀── 201 Created ─────────────│
│ (timeout!) │
│── POST /charges ───────────▶│
│ Idempotency-Key: K │ hash(...) = F (matches)
│ │ GET idemp:K → {F, 201, body}
│◀── 201 Created ─────────────│ (cached, not re-executed)
What gets stored
You want every byte of the response in the cache. Status code, headers (Location, Stripe-Request-Id, etc), body. The retry should be byte-identical to the original — that's the whole point. If you only store the status code, your retry might miss the Location header that told the client where the new resource lives.
HSET idemp:K fingerprint sha256(method|path|body)
status 201
headers {"location": "/charges/ch_abc"}
body {"id":"ch_abc","amount":5000,"status":"succeeded"}
EXPIRE idemp:K 86400
Redis hashes are the obvious fit. DynamoDB with TTL works if you're already in AWS (see AWS Lambda Powertools). Postgres works fine if you don't want another datastore.
Body-mismatch — the 422 case
A client that reuses the same key with a different body is almost always a bug — they accidentally regenerated half the request but kept the key. The right behaviour is to refuse:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/idempotency-key-reused",
"title": "Idempotency-Key already used with a different request",
"status": 422,
"key": "8e6e4c0f-..."
}
Do not silently overwrite the prior request. Do not silently return the prior response either — both hide a real bug. Loud failure is correct.
TTL — how long is long enough
Stripe holds keys for 24 hours. AWS Powertools defaults to 1 hour. The right answer depends on your retry budget: long enough that any reasonable client retry (including a queue-based retry from a separate service) lands within the window, short enough that storage doesn't grow without bound.
24 hours is the safe default. Less than 1 hour is risky for any system with downstream queues that can lag. More than 7 days is wasteful unless you have a specific reason.
What you do NOT need
- You don't need an Idempotency-Key on GET, HEAD, OPTIONS — they're already safe.
- You don't need one on PUT and DELETE — they're idempotent by spec.
- You don't need one on PATCH — only if your PATCH is non-idempotent (most aren't if designed well).
- You don't need one on POST that's actually a
readaction — a POST that does a search shouldn't be POST in the first place.
The header earns its keep on POST that creates state. Charges, orders, transfers, sends. Add it there and you stop treating retries as a bug.
Implementation traps
Storing the request body in the cache. No — store a hash of it. Bodies can be megabytes. Storing fingerprint + response is enough.
Forgetting concurrent retries. Two clients, same key, racing. Without a lock the second one will execute before the first one finishes writing the cache. Solve with a "claim" entry written before execution: SET idemp:K {pending} NX EX 60. If the SET fails, you know another request is in flight — wait or 409.
Treating the key as a session token. No. The key is per-request, per-intent. A new charge gets a new key, even from the same user, same session.
Skipping the body fingerprint. If you don't fingerprint, a buggy client that recycles the key for a different charge will get the original response and never know it failed. Fingerprint, compare, 422 on mismatch.
When to add this
If you have any POST that mutates state and is called from a network you don't control (a mobile app, a webhook receiver, a background queue) — add it now. The cost is tiny: one Redis hash, one middleware. The cost of NOT having it shows up in support tickets that say "I was charged twice."
This is one of those rare patterns where the marginal cost of the right answer is roughly zero and the marginal cost of the wrong answer is unbounded. Pay the cost up front.
Tools in the wild
3 tools- service
Reference implementation — every Stripe POST accepts Idempotency-Key with 24h dedup.
- libraryAWS Lambda Powertoolsfree tier
Drop-in idempotency for Lambda handlers backed by DynamoDB.
- libraryIdempotent.devfree tier
Postgres-backed library implementing the IETF draft semantics in Node.