Designing Idempotent APIs: Retries Without Double Charges#
Every network call can fail in three ways: it never reached the server, it reached the server and failed, or it succeeded and the response got lost on the way back. The client cannot tell the last two apart. So it retries — and if your endpoint is not idempotent, a retry can create a second order, send a second email, or charge a card twice.
After shipping payment and affiliate-payout flows, I treat idempotency as a requirement for every endpoint that changes state. This post covers how to design it properly.
What idempotency actually means#
An operation is idempotent when performing it once or many times leaves the system in the same state. GET, PUT and DELETE are idempotent by HTTP semantics. POST is not — and POST is exactly where the dangerous operations live: create payment, place order, trigger payout.
The goal is not that the second request does nothing. The goal is that the second request returns the same result as the first without repeating the side effect.
The idempotency key pattern#
The client generates a unique key per logical operation and sends it with the request:
1POST /v1/payouts2Idempotency-Key: 7c4a8d09-ca37-4b6e-9f3a-1e2f8b6d4c213Content-Type: application/json4 5{ "affiliateId": "aff_123", "amount": 15000, "currency": "USD" }
The server stores the key together with the request fingerprint and the response. On a repeat, it replays the stored response instead of executing again.
1CREATE TABLE idempotency_keys (2 key text PRIMARY KEY,3 user_id uuid NOT NULL,4 request_hash text NOT NULL,5 status text NOT NULL, -- 'processing' | 'completed'6 response_code int,7 response_body jsonb,8 created_at timestamptz NOT NULL DEFAULT now()9);
Handling concurrent duplicates#
The hard case is not a retry five seconds later. It is two identical requests arriving at the same millisecond, from a double-click or an aggressive retry policy. A naive "check, then insert" has a race window.
Let the database resolve the race. Insert the key first, with a unique constraint, and only the winner proceeds:
1async function withIdempotency<T>(key: string, userId: string, body: unknown, run: () => Promise<T>) {2 const hash = sha256(JSON.stringify(body));3 4 const inserted = await db.query(5 `INSERT INTO idempotency_keys (key, user_id, request_hash, status)6 VALUES ($1, $2, $3, 'processing')7 ON CONFLICT (key) DO NOTHING8 RETURNING key`,9 [key, userId, hash],10 );11 12 if (inserted.rowCount === 0) {13 const existing = await db.one(`SELECT * FROM idempotency_keys WHERE key = $1`, [key]);14 if (existing.request_hash !== hash) throw new UnprocessableEntity("Key reused with a different payload");15 if (existing.status === "processing") throw new Conflict("Request is still being processed");16 return existing.response_body as T;17 }18 19 const result = await run();20 await db.query(21 `UPDATE idempotency_keys SET status = 'completed', response_code = 200, response_body = $2 WHERE key = $1`,22 [key, result],23 );24 return result;25}
Three rules hide in that code:
- Scope keys to the caller. A key from user A must never replay a response to user B.
- Fingerprint the payload. Reusing a key with a different body is a client bug; reject it with
422 instead of silently returning the old result. - Return
409 while in flight. The client should back off and retry, not start a parallel execution.
Make the side effect itself safe#
The idempotency table protects your API boundary. It does not protect what happens behind it. If your handler calls a payment provider and then crashes before saving the result, the retry will call the provider again.
Two practices close that gap:
- Pass the key downstream. Most payment providers accept their own idempotency key. Derive it from yours so the provider deduplicates too.
- Record intent before acting. Write a
pending payout row in the same transaction as the key, then call the provider, then mark it completed. A reconciliation job can safely finish or roll back anything stuck in pending.
Expiry and storage#
Keys do not need to live forever. Twenty-four hours covers every realistic retry window. A nightly job deleting old rows keeps the table small:
1DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
If the table is hot, Redis with SET key value NX EX 86400 gives you the same atomic insert-or-fail semantics with automatic expiry.
Client-side discipline#
The server can only deduplicate what the client labels correctly:
- Generate the key once per user intent, not once per HTTP attempt. Store it with the pending form submission.
- Retry with exponential backoff and jitter, and only on network errors,
409, 429 and 5xx. - Never retry
4xx validation errors — the same request will fail the same way.
Conclusion#
Idempotency is cheap to add at design time and very expensive to retrofit after the first double charge. A unique key, an atomic insert, a stored response and a downstream key cover nearly every case. Build it once as middleware and every state-changing endpoint gets retry safety for free.