Retried requests.
One record.
A request can arrive twice: timeouts, double clicks, client retries. Retry one: the naive API records the request again; with an idempotency key the retry replays the first response.
Naive, charges every request
- customer charged
- $0
- overcharged
- $0
Idempotency key, one charge per key
- customer charged
- $0
- replayed
- $0
Send a request, then retry it: networks time out, users double-click, clients resend. That was the same request: the naive API charged the card again; the idempotent one replayed the first response.
How idempotency keys actually work read more
If a request times out, the client can't tell whether it worked. Maybe only the response got lost on the way back. Retrying is the only safe option. But a naive API treats that retry as a brand new request, so the payment goes through twice.
The fix: the client makes up one key per payment and sends it with every attempt, including retries. The API saves that key, together with the response it's about to send back, in the same transaction as the payment itself:
BEGIN;
INSERT INTO idempotency_keys (key, response) VALUES ($1, $2);
-- unique(key): a retry hits this and gets the stored response back
INSERT INTO payments (..., status='paid');
COMMIT; A retry sends the same key, the unique constraint blocks the second insert, and the API just hands back the response it already saved, so a replay looks identical to the original request. The order matters: if the API checked for the key first and inserted it second, two retries arriving at the same moment could both pass the check and both go through.
The trade-off: the client has to create that key and hold onto it even if it crashes, the API has to store every response, and someone has to decide how long a key stays valid. None of that is a default. It's a decision.