Idempotency keys are how a retry stops charging twice
The claim Any operation that changes something and can be retried — a payment, an order, a message send — will eventually be retried after it already succeeded, because the network...
The claim
Any operation that changes something and can be retried — a payment, an order, a message send — will eventually be retried after it already succeeded, because the network dropped the response before the caller saw it. Without a way to recognise the repeat, the retry runs the operation a second time and you charge the customer twice, create two orders, or send two emails. An idempotency key is the small mechanism that makes a repeated request produce the original result instead of a second effect, and it is not optional on anything involving money.
The failure that guarantees this happens
Consider a payment request. Your server receives it, charges the card successfully, and begins sending the response — and at that instant the customer's connection drops. The charge happened; the confirmation never arrived. From the client's side, the request appears to have failed, so it does the reasonable thing and retries. Your server, with no memory that it has seen this request before, charges the card again. The customer is billed twice for one purchase, and the only record that anything went wrong is a confused customer and a chargeback.
This is not an edge case you can hope to avoid. Networks drop responses routinely, clients retry by design, and mobile connections are especially prone to it. Any create-or-charge operation exposed to an unreliable network will encounter this, and the question is only whether you handled it before it happened or after a customer noticed.
The mechanism
The client generates a unique key for each logical operation — not each HTTP attempt — and sends it with the request. The server records the key the first time it sees it, along with the result, and on any subsequent request bearing the same key it returns the stored result instead of performing the operation again.
POST /charges
Idempotency-Key: 4f3c1a90-8b2e-4e21-9c77-1d0e2a5b6f88
-- server side, the whole idea:
INSERT INTO idempotency_keys (key, status)
VALUES ($1, 'processing')
ON CONFLICT (key) DO NOTHING;
-- if the insert affected 0 rows, this key was seen before:
-- return the stored response, do NOT charge again.
The unique constraint on the key column is what makes this safe under concurrency: if two copies of the same retried request arrive simultaneously, the database lets exactly one win the insert and the other sees the conflict, so even a race cannot produce two charges.
The key must identify the operation, not the attempt
The single most common mistake is generating the key in the wrong place. If the client creates a new key each time it sends the request, including on the retry, then the retry carries a different key and is treated as a new operation — which defeats the entire purpose. The key must be generated once, when the user takes the action, and reused across every retry of that same action. It identifies the intent — "this one purchase" — not the transmission.
// correct: key created when the user clicks Pay, reused on every retry
const key = crypto.randomUUID(); // once, at intent
async function pay() {
return post('/charges', body, { 'Idempotency-Key': key }); // retries reuse key
}
Store the response, not just the key
Recording that a key was used is only half the mechanism; you must also store what to return when it recurs. The first successful request saves its response body and status against the key, and the repeat returns exactly that — so the client, whichever attempt finally reaches it, receives the same answer it would have received the first time. Without the stored response, the best you can do on a repeat is report that the operation already happened, which the client does not know how to interpret; with it, the retry is genuinely transparent.
Handle the request that is still in flight
There is a subtle case between "never seen" and "completed": the original request is still processing when the retry arrives. The retry must not start a second execution, but it also cannot return a result that does not exist yet. The clean handling is to mark the key as processing on first insert and, when a request arrives for a key already in that state, return a status that tells the client to wait and retry shortly rather than proceeding. This is why the key row carries a status, not just a presence — it distinguishes done from in-progress, and the two need different responses.
Expire keys, but not too soon
Idempotency keys do not need to live forever, and letting them accumulate indefinitely turns the table into a slowly growing liability. Expire them after a window that comfortably exceeds any realistic retry — 24 hours is a common and safe choice, since no legitimate client retries a purchase a day later. Keep the window long enough that every retry of a real operation falls inside it, because a key that expires between the original request and a delayed retry reopens exactly the double-charge hole the mechanism was there to close.
Where to apply it
Put idempotency keys on every operation where a duplicate is harmful and a retry is possible: charging a card, placing an order, issuing a refund, sending a transactional message, provisioning a paid resource. Read-only operations do not need them, because repeating a read costs nothing. The rule is simple and worth stating plainly: if running it twice would hurt, and the network could cause it to run twice, it needs an idempotency key — and the payment path needs it before you launch, not after the first customer is charged twice and you are issuing an apology along with the refund.