Idempotency Keys: The API Pattern That Prevents Double Charges
Photo: Unsplash
A customer taps "Pay" once. Their phone loses signal mid-request, the app retries, and now there are two charges on their card. The bug isn't in your payment logic — it's in the assumption that a request happens exactly once. Over an unreliable network, the only guarantees you get are at most once or at least once. Idempotency keys are how you turn "at least once" delivery into "exactly once" effect.
This is not an exotic technique. It's how Stripe, PayPal, and every serious payments API survive the real world. The pattern is small, the discipline is the hard part.
Why Retries Are Unavoidable
When a client sends a POST /charges and the connection drops before the response arrives, the client has no idea whether the server processed it. Three things could have happened: the request never arrived, it arrived and succeeded but the response was lost, or it failed. The client cannot distinguish these from the outside.
Its only safe options are to give up (and risk a lost payment) or retry (and risk a double charge). Retrying is almost always the right product choice — which means your server must make retries safe. An operation is idempotent when performing it multiple times has the same effect as performing it once. GET, PUT, and DELETE are idempotent by definition in the HTTP spec; POST is not, and creating a charge is a POST.
Idempotency is about effect, not response. A retried request can safely return the original result — it just must not perform the side effect a second time.
The Key Is a Client-Generated Token
The client generates a unique key — a UUID is ideal — and sends it with the request. On a retry, it sends the same key. The server uses that key to recognize "I have seen this exact operation before."
// Client: generate ONCE, reuse on every retry of the same logical operation
const idempotencyKey = crypto.randomUUID();
async function pay(amount: number) {
return withRetries(() =>
fetch("/charges", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // same key across retries
},
body: JSON.stringify({ amount, currency: "usd" }),
})
);
}The critical detail people get wrong: the key must be generated before the first attempt and reused across the whole retry loop. Generating a fresh key inside the retry defeats the entire mechanism.
Server-Side: Store First, Act Once
The server records the key before doing the work, and uses a unique constraint to let the database arbitrate races. Two concurrent retries hitting at the same millisecond is the case that breaks naive implementations.
CREATE TABLE idempotency_keys (
key UUID PRIMARY KEY,
request_hash TEXT NOT NULL, -- guard against key reuse with a different body
response_code INT,
response_body JSONB,
status TEXT NOT NULL DEFAULT 'in_progress',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);The flow inside a single database transaction:
-- Atomically claim the key. If it already exists, we lose the race
-- and fall through to returning the stored result.
INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ('a1b2...', 'sha256-of-body', 'in_progress')
ON CONFLICT (key) DO NOTHING;If the INSERT inserted a row, you are the first caller: perform the charge, then update the row with the stored response and status = 'completed'. If the INSERT did nothing, someone else already owns this key — return their stored response (or, if still in_progress, return 409 Conflict so the client backs off and retries).
The request_hash matters: if a client reuses a key with a different body, that's a bug on their side, and returning a 422 is safer than silently applying the wrong stored response.
Scope, Expiry, and the Edge Cases
A few decisions separate a toy implementation from a production one:
- Scope keys per customer or per account. A globally unique key space invites accidental collisions and information leaks.
- Expire keys. Stripe retains them for 24 hours; that's plenty for retry windows while keeping the table from growing forever. Past expiry, a reused key is treated as a brand-new request.
- Handle the in-progress window. If the first request is still executing when a retry arrives, don't run the operation twice — return
409and let the client wait. - Persist the response, not just the fact of success. The whole point is that the retry gets the same answer, including the charge ID.
Storing the key but performing the side effect outside the transaction reintroduces the bug. The record of "I did this" and the act of doing it must commit together, or a crash between them leaves you inconsistent.
For a deeper treatment of designing these guarantees into an API, the Stripe engineering blog on idempotency and the AWS guidance on idempotent APIs are both worth reading end to end.
Beyond Payments
Idempotency keys aren't just for charges. Any operation with a non-reversible side effect benefits: sending an email, provisioning a resource, submitting an order, publishing an event. Anywhere a client might retry — which, on a real network, is everywhere — the same pattern applies. Many message brokers offer "exactly-once" semantics that are, under the hood, idempotency keys plus deduplication; understanding the pattern means you can reason about those claims instead of trusting them blindly.
Takeaways
- Network uncertainty makes retries unavoidable; your API must make them safe.
- The client generates a unique idempotency key once and reuses it across every retry of the same logical operation.
- The server stores the key with a unique constraint and lets the database arbitrate concurrent retries.
- Record the key and perform the side effect in the same transaction, and persist the full response for replay.
- Scope keys per account, expire them, and reject key reuse with a mismatched request body.
- The pattern generalizes to any non-idempotent side effect, not just payments.

