Idempotency keys on write endpoints
Why every non-GET endpoint that touches money or external state needs an idempotency key, and how to implement one without footguns.
On this page
The problem teams keep rediscovering
A client retries a POST /payments. The first call succeeded but the response was lost. The retry creates a second payment. Now the system owes the user a refund and the team owes itself an incident review.
This happens every time a non-idempotent write meets an unreliable network. Which is always.
Support tickets: “I was charged twice for one order.” Several arrive within hours after a brief network blip in a mobile client.
Logs show the mobile app retrying POST /payments after a socket timeout. The first
call had succeeded; the response never made it back. The retry is treated as a new request.
POST /payments has no idempotency contract. The server has no way to recognise the retry.
Add Idempotency-Key header support. Store attempts keyed by the header in a dedicated
table. Replays return the original response.
Every new write endpoint ships with idempotency wired through the framework. A lint rule can flag POST handlers that don’t accept the header.
The contract
The client sends a header — Idempotency-Key: <opaque uuid> — that uniquely identifies the attempt, not the resource. The server promises:
-
First time we've seen this key
Process the request. Store the full response keyed by the header. Return it.
-
Replayed key, completed
Return the stored response. Do not re-process anything.
-
Replayed key, still in flight
Block (or return 409) until the original finishes, then return its response.
-
Expire after the window
24h is standard. The key + response can be garbage-collected after that.
The key is the client’s responsibility. They mint it once per logical operation and reuse it across retries.
The flow
The decision: where to store the record
Durability matters more than the extra ~2ms. The database is already operated; a separate cache would be one more system to run. Hashing was tempting and would have been the wrong call.
- Simple
- Audit-friendly
- Works with existing RDBMS
- One extra round-trip per write
- Fast
- Built-in expiry
- Another moving part
- Loses durability guarantees on eviction
- No client cooperation
- Two semantically identical retries with different timestamps charge twice — the standard footgun
The footguns
Server-side: idempotencyKey = sha256(body). No header required.
It avoids a client API change. Looks like a free win.
Make the client mint a UUID per attempt. Two retries with a differing requestedAt
timestamp will hash differently and double-charge.
Other common mistakes:
- Storing only the resource ID, not the full response. On replay you can’t reconstruct the original
201 Createdbody. - Ignoring request-body equality. If the client reuses a key with a different body, that’s a client bug — return 422, don’t silently honour the cached response.
- Forgetting the in-flight state. Concurrent retries arrive before the first finishes. Use
INSERT ... ON CONFLICT DO NOTHINGand treat the lookup as the source of truth.
What to remember
Related on the read side: 0001 JPA N+1 query problem · Java — same shape of “cheap up front, very expensive once in production”.