Making a Write Safe to Retry
Idempotency keys: what the key covers, how long you remember it, and why replaying the stored response beats redoing the work.
Idempotency-Key: 7f9c1e2a-... (on POST; GET and DELETE are idempotent already)A client POSTs a charge. The response is lost to a timeout, so the client retries. Without help the customer is charged twice, and the client cannot tell the difference between a request that never arrived and a response that never came back.
What the key covers
The client generates a key — a v4 UUID is the usual choice — and sends it as Idempotency-Key. The server stores, against that key, a fingerprint of the request and, once the work completes, the status code and response body. Scope the key to the endpoint and the authenticated account, never globally: two tenants that generate the same string must not see each other's results. Stripe accepts the header on all POSTs, allows keys up to 255 characters, and prunes stored results after 24 hours, after which a retry is simply a new request.
Same key, different body
That is a client bug and must not be silently accepted, or a retry with an edited amount replays the old response. The IETF draft for the header (draft-ietf-httpapi-idempotency-key-header, an Internet-Draft, not an RFC) specifies 422 for a key reused with a different payload, 409 while the first request is still in flight, and 400 when the header is missing on an endpoint that requires it.
Replay, do not re-run
Return the stored response rather than recomputing it: the second caller then sees the same id, the same timestamps and the same 500 if that is what the first caller got. One exception worth copying from Stripe — do not store a result when the request failed validation or collided with a concurrent one, because the client must still be able to retry those.
Example
// middleware/idempotency.js — wrap a handler that returns { status, body }
import crypto from 'node:crypto';
export function idempotent(handler) {
return async (req, res, next) => {
const key = req.get('Idempotency-Key');
if (!key) return res.status(400).json(problem('idempotency-key-required', 400));
// The fingerprint is what makes "same key, different body" detectable.
const fingerprint = crypto.createHash('sha256')
.update(`${req.method}\n${req.route.path}\n${JSON.stringify(req.body)}`)
.digest('hex');
// INSERT ... ON CONFLICT DO NOTHING is the lock: one winner, atomically.
// Requires UNIQUE (account_id, endpoint, key).
const claim = await db.query(
`INSERT INTO idempotency_keys
(account_id, endpoint, key, fingerprint, state, expires_at)
VALUES ($1, $2, $3, $4, 'in_progress', now() + interval '24 hours')
ON CONFLICT (account_id, endpoint, key) DO NOTHING`,
[req.principal.accountId, req.route.path, key, fingerprint]);
if (claim.rowCount === 0) {
const { rows: [prior] } = await db.query(
`SELECT fingerprint, state, response_status, response_body
FROM idempotency_keys
WHERE account_id = $1 AND endpoint = $2 AND key = $3`,
[req.principal.accountId, req.route.path, key]);
if (prior.fingerprint !== fingerprint)
return res.status(422).json(problem('idempotency-key-reused', 422));
if (prior.state === 'in_progress')
return res.status(409).json(problem('idempotency-request-in-flight', 409));
// Replay: same status, same bytes the first caller got.
return res.status(prior.response_status).json(prior.response_body);
}
try {
// Ideally the business write and this UPDATE share one transaction,
// so a crash cannot leave a charge with no recorded response.
const { status, body } = await handler(req);
await db.query(
`UPDATE idempotency_keys SET state='complete', response_status=$1, response_body=$2
WHERE account_id=$3 AND endpoint=$4 AND key=$5`,
[status, body, req.principal.accountId, req.route.path, key]);
res.status(status).json(body);
} catch (err) {
// Release the key so a transient failure stays retryable.
await db.query(`DELETE FROM idempotency_keys
WHERE account_id=$1 AND endpoint=$2 AND key=$3`,
[req.principal.accountId, req.route.path, key]);
next(err);
}
};
}When to use it
- A payments endpoint where a client timeout currently produces duplicate charges
- A queue worker that may redeliver the same job and calls a third-party API
- Deciding what to return when a mobile client retries a POST while the first is still running
- Setting a retention window for stored idempotency results
More examples
The table and the constraint that does the work
The unique constraint is the whole concurrency control — there is no advisory lock and no application-level mutex. Keep the retention short and reap aggressively, because this table takes a write on every mutating request and will become your largest table if you let it.
CREATE TABLE idempotency_keys (
id bigserial PRIMARY KEY,
account_id bigint NOT NULL,
endpoint text NOT NULL,
key text NOT NULL CHECK (length(key) <= 255),
fingerprint char(64) NOT NULL, -- sha256 of method + path + body
state text NOT NULL, -- in_progress | complete
response_status smallint,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
-- Scoped per account: one tenant's key cannot collide with another's.
UNIQUE (account_id, endpoint, key)
);
CREATE INDEX ON idempotency_keys (expires_at); -- for the reaper
-- Run on a schedule; 24h is Stripe's window and a reasonable default.
DELETE FROM idempotency_keys WHERE expires_at < now();Replay seen from the client side
Worth keeping as a smoke test against staging for any endpoint that moves money. The third call is the one teams forget to implement; without it a client that mutates its payload between retries gets a stale success and believes the new amount went through.
KEY=$(uuidgen)
# First attempt: times out after the server has already charged the card.
curl -sS -X POST https://api.example.com/charges \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d '{"amount_cents": 2500, "currency": "usd", "source": "pm_1A2B"}' --max-time 2
# curl: (28) Operation timed out
# Retry with the SAME key: 201 again, same charge id, nothing charged twice.
curl -i -X POST https://api.example.com/charges \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d '{"amount_cents": 2500, "currency": "usd", "source": "pm_1A2B"}'
# HTTP/1.1 201 Created -> {"id":"ch_01HV...","amount_cents":2500}
# Same key, edited amount: rejected rather than silently replayed.
curl -i -X POST https://api.example.com/charges \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' -d '{"amount_cents": 9900, ...}'
# HTTP/1.1 422 Unprocessable ContentGenerating the key in the right place
Server-side idempotency is only half the mechanism; the client has to hold the key still. The derived-key form at the bottom is what you want inside a queue worker, where the retry is a redelivery in a new process and an in-memory UUID is long gone.
// WRONG: a new key per attempt means every retry is a new charge.
async function chargeBad(body) {
return retry(() => fetch(URL, {
method: 'POST',
headers: { 'Idempotency-Key': crypto.randomUUID() }, // new key each try
body: JSON.stringify(body),
}));
}
// RIGHT: one key per logical operation, reused by every retry of it.
async function charge(body) {
const key = crypto.randomUUID(); // generated once, outside retry
return retry(() => fetch(URL, {
method: 'POST',
headers: { 'Idempotency-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify(body), // byte-identical on every attempt
}));
}
// Better still for a job that may be re-run after a process restart:
// derive the key from something durable, so a crashed worker reuses it.
const key = `charge:${invoice.id}:attempt:${invoice.payment_attempt}`;
Discussion