Design idempotency for a "create payment" endpoint so that client retries never double-charge. Walk through the request flow and the edge cases.
Key points
- Client generates a unique idempotency key per logical operation and sends it on every retryIn plain words: The client makes up a unique ticket number for each action and sends the same number every time it retries
- Server atomically records the key before doing the work, so a concurrent duplicate sees it and waits or returns the in-progress resultIn plain words: The server writes the ticket number down before doing anything, so a twin request arriving at the same moment sees the work is under way
- Store the final response against the key and replay it for later retries with the same keyIn plain words: The server keeps the answer next to the ticket and hands back the same answer on any later retry
- Reject a reused key with a different request body, it indicates a client bugIn plain words: If the same ticket arrives with different details, refuse it, because the client has a bug
- Keys expire after a window (hours to days) and are scoped per account to avoid collisionsIn plain words: Tickets are thrown away after a while and belong to one account, so two customers never clash
Model answer
A client calls POST /payments, the network drops the response, and the client retries. Without protection the customer is charged twice. Idempotency keys make the retry safe.
Flow
- The client generates a UUID for the intent ("pay this invoice") and sends it as
Idempotency-Key. Every retry of that intent reuses the same key. - The server does an atomic insert into an
idempotency_keystable keyed by(account_id, key)with statusin_progressand a hash of the request body.- Insert succeeds: this is the first attempt; proceed.
- Insert conflicts and the stored body hash differs: return
422, the client reused a key for a different request. - Insert conflicts and status is
completed: return the stored response (same status code and body). - Insert conflicts and status is
in_progress: return409with aRetry-After, or block briefly on the row lock and then return the completed result.
- Perform the work. If it involves side effects outside your database (calling a card network), pass your own idempotency key downstream so their retries are safe too.
- Store the response against the key and mark it
completed, ideally in the same transaction as the business write so they cannot diverge.
Edge cases
- Crash between step 3 and 4: the key stays
in_progress. Use a lease timeout so a retry after the lease can re-run from a checkpoint, or make the business write and key update one transaction. - Expiry: keep keys for 24 hours or more; after that a retry is treated as new, which is acceptable because clients do not retry for days.
- Scope: namespace keys by account so two tenants can never collide.
State the atomic insert, the stored response, the mismatch check, and the TTL.
Likely follow-ups
- What should happen if the first request is still in flight when the retry arrives?
- How does this interact with downstream calls to a bank that is itself not idempotent?