Architecture patterns
Idempotency keys
For operations that can't be made idempotent by design, a client-chosen key lets the server recognize a retry and replay the first answer. Learn the protocol and its failure cases.
Some operations can't be redesigned to be safe to repeat. "Create a payment" is one. For those, the client attaches a unique idempotency key to the logical operation and sends the same key with every attempt. The server remembers which keys it has seen and what it answered. Stripe popularized this with an Idempotency-Key HTTP header, and the IETF HTTPAPI working group has a draft to standardize it.
The client side
The client makes the key once per logical operation, usually a UUIDv4, and saves it before the first attempt. If the user taps "Pay" and the app retries three times, all three attempts carry the same key. If the user later buys something else, that's a new operation and a new key.
Generating a fresh key for each attempt is the most common bug. It defeats the entire mechanism.
The server side
The server stores one row per key, scoped to the account so two customers can't collide. The row holds a hash of the request, the status, and the final response.
CREATE TABLE idempotency_keys (
account_id bigint NOT NULL,
key text NOT NULL,
request_hash bytea NOT NULL,
status text NOT NULL, -- 'started' or 'completed'
response_code int,
response_body jsonb,
locked_until timestamptz,
PRIMARY KEY (account_id, key)
);The handler then follows a fixed order. This is a sketch: db, reply and cardNetwork stand in for your own helpers.
async function handleCharge(req: ChargeRequest, accountId: number, key: string) {
const hash = sha256(canonicalJson(req));
// 1. Claim the key. The primary key makes concurrent duplicates collide here.
const row = await claimKey(db, accountId, key, hash); // inserted, or the existing row
if (!row.inserted) {
if (!row.request_hash.equals(hash)) return reply(422, "Key reused with different parameters");
if (row.status === "completed") return reply(row.response_code, row.response_body);
if (row.locked_until > new Date()) return reply(409, "Request with this key is in progress");
// The lock expired: a previous attempt crashed. Fall through and resume.
}
// 2. Do the work. Pass a derived key downstream so the card network deduplicates too.
const charge = await cardNetwork.charge(req, { idempotencyKey: `${accountId}:${key}` });
// 3. Record the outcome, so every retry gets the same answer.
await markCompleted(db, accountId, key, 201, charge);
return reply(201, charge);
}Each step handles a specific failure:
- Request hash. If a client reuses a key for a $99 charge after using it for $49, that is a client bug. Reject it loudly with 422.
- Stored response. A retry gets the same answer as the original, including the charge ID.
- Concurrency. Two attempts that arrive together can't both insert. The second sees
startedand gets 409, telling the client to wait and retry. - Crash recovery. If the server dies between steps 1 and 3, the lease expires and a later retry takes over. This is safe only because step 2 passes a key downstream. The card network returns the original charge rather than creating a second one.
- Expiry. Keys can't live forever. Stripe prunes keys after at least 24 hours. A retry after that is treated as new, so pick a window longer than your clients' retry horizon.
Warning
Putting the side effect inside a database transaction that also writes the key does not make it atomic. The transaction can roll back, but an HTTP call to a card network can't be undone. The protocol works because each external call is itself idempotent.
Common mistakes
- A new key per attempt. Retries then look like new operations.
- Not hashing the parameters. The same key with a different body silently returns the old result.
- Not passing the key downstream. Your server can be safe while the provider charges twice.
- Expiring keys too soon. A key that expires before the client stops retrying lets duplicates through.
Check yourself
1. A client reuses a key from a $49 charge for a $99 charge. What should the server do? Reject it with 422. The same key with different parameters is a client bug, and returning the $49 result would be silently wrong.
2. The server crashes after the card network charged but before it saved the result. What protects the retry? The lease lets the retry take over, and the derived key sent downstream makes the card network return the original charge.
Key takeaways
- Use one key per logical operation, saved before the first attempt.
- The server claims the key atomically, checks a request hash, and stores the response for replay.
- Concurrent duplicates get 409 while the first attempt runs.
- Pass a derived key to every downstream system that has side effects.
Further learning
Stripe's own write-up covers the design decisions behind the header.
Stripe: Designing robust and predictable APIs with idempotencyOnline payment processing for internet businesses. Stripe is a suite of payment APIs that powers commerce for businesses of all sizes.stripe.com
Next, see how consumers apply the same idea to incoming messages in deduplicating in message consumers.
Related
Case study: payment system
Moving money correctly, with payment intents, idempotency at every hop, a double-entry ledger, webhooks and reconciliation when the network lies. Start here for the payments map.
Naturally idempotent operations
Some operations are safe to repeat by design, such as absolute sets, upserts and conditional writes. Learn to spot them, and to redesign the rest before you add keys.
Retry policy and backoff with jitter
Idempotency makes retries safe, and a retry policy makes them useful. Learn which errors to retry, how to space attempts with exponential backoff and full jitter, and when to stop.