A payments API receives the same charge request twice because a mobile client retried after a timeout. How should idempotency keys be scoped, stored, expired and validated, and what happens when the first attempt is still in flight?
Show the full answer Hide the answer
Why the client is right to retry
A timeout tells the client nothing about whether the server processed the request. The request may have succeeded and the response been lost. The only safe options are retry or leave the user in an unknown state, and for a payment the second is unacceptable. So the client will retry, and the server must make that safe.
Scoping
The key is supplied by the client and must be unique to the logical operation, not to the HTTP attempt. It is scoped per API key or per account, never globally — a global namespace lets one tenant's key collide with another's, which is both a correctness and an isolation failure.
The key must not be derived from the payload. Two genuinely distinct charges for the same amount to the same customer within the same second are a real scenario, and hashing the body merges them.
Storage and the record
Store, keyed by (account, idempotency_key):
- A fingerprint of the request parameters, to detect misuse.
- The state: in-progress, or complete.
- The full serialised response — status code and body — for replay.
- A creation timestamp for expiry.
On a repeat with the same key and the same fingerprint: return the stored response byte-for-byte, including the original status code. The client cannot distinguish a replay from the original, which is the point.
Validation against a differing payload
Same key, different fingerprint → reject with a distinct error. This is a client bug — key reuse across different operations — and silently processing it is far worse than a loud failure, because it means either a lost charge or a duplicate one depending on which way the implementation guesses.
The in-flight case, which is the hard one
Same key arrives while the first attempt is still executing. Returning "not found" causes a duplicate charge; waiting is unbounded. The correct behaviour is to take a lock on the key at the start of processing and return an explicit "a request with this key is in progress, retry shortly" status, which is a documented, retryable condition.
The lock must have a timeout, or a crashed worker blocks that key permanently. And the timeout must be longer than the maximum plausible processing time, or two attempts genuinely overlap.
Expiry
Keys expire after a bounded window — 24 hours is a common choice — because retention is unbounded storage of full request/response pairs, and the useful retry window is minutes. State this in the API contract: a retry after expiry is a new operation, and clients must know that.
The part usually missed
Idempotency must extend to the side effects, not only the API response. If the first attempt sent an email, enqueued a fulfilment job and wrote a ledger entry, the replay must not repeat any of them. That means the side effects are performed inside the same transaction as the idempotency record, or dispatched through an outbox that is itself keyed — otherwise the API is idempotent and the system is not.