HTTP Idempotency-Key for safe retries
Mobile networks drop. Load balancers time out. Users mash “Pay” twice. Your client retries a
POST because it never saw a response — but your server may have already created the
charge, invite, or export job. Without an explicit client-supplied key, every retry looks like a
brand-new request. The Idempotency-Key header is how serious APIs (Stripe popularized
the pattern) turn “maybe again” into “exactly the same outcome.”
This guide is about client-driven idempotency for your own HTTP APIs — not provider webhook event ids (see the webhook idempotency guide). Both use unique keys in Postgres; the trust boundary and payload rules differ.
What the header means
The client generates a key (UUID v4 is fine) before the first attempt and sends it on every retry of the same logical operation:
POST /v1/charges HTTP/1.1
Idempotency-Key: 8f3c2a1b-9e44-4d2a-b7c1-0a1f2e3d4c5b
Content-Type: application/json
{"amount": 1999, "currency": "usd", "customerId": "cus_…"}
Your server’s contract: for a given authenticated actor (and usually a given route), the first
successful processing of that key wins. Later requests with the same key and the same body return
the stored status and body — they do not create a second charge. If the same key arrives
with a different body, reject with 409 or 422; that is a client bug,
not a retry.
What to store in Postgres
A minimal table:
IdempotencyRecord
scope text -- e.g. userId or apiKeyId + route
key text
requestHash text -- hash of method + path + canonical body
statusCode int -- null while in flight
responseBody jsonb -- null while in flight
createdAt timestamptz
UNIQUE (scope, key)
On request: compute requestHash, then INSERT … ON CONFLICT DO NOTHING
(or equivalent) for (scope, key). If you inserted, you own the work — run the handler,
then update the row with status and response. If the insert lost the race, read the existing row:
if complete, replay; if still in flight, return 409 with a short Retry-After
or wait briefly and re-read. Never start a second side effect while the first is unfinished.
Scope matters. Keys are not globally unique across all users; they are unique per customer (or API
key) so Alice’s abc never collides with Bob’s. Include the route or operation name in
the scope if one user might reuse keys across unrelated endpoints.
GET is already safe; focus on POST/PATCH
Idempotent methods (GET, PUT with full replacement, DELETE
of a known resource) usually do not need this header. The high-value cases are
POST creates and non-idempotent PATCH actions: payments, invites, “run
export,” “rotate secret.” Document which routes require the header. Reject missing keys with
400 on those routes so clients learn the contract early.
TTL, cleanup, and storage cost
Keep records for at least 24 hours; many APIs keep 24–72 hours. Longer retention helps slow
mobile retries but grows the table. Index (scope, key) and optionally expire with a
nightly job on createdAt. Store response bodies only when they are reasonably small;
for huge payloads, store a resource id and rebuild a thin acknowledgment on replay.
How this differs from webhook external ids
-
Webhooks: the provider owns the event id. You verify HMAC, then unique on
(provider, externalId). See HookQueue’s ingest pattern. - Idempotency-Key: your client owns the key. You trust auth first, then the key within that scope. No HMAC of a third-party payload — but you must hash the request to detect key reuse with a different body.
Rate limits (RateGuard) sit beside this: a key does not exempt a client from quotas. Idempotent retries should still count toward limits carefully — some APIs count only the first attempt — but never skip auth or tenant checks because a key is present.
Client checklist
- Generate the key once per user action; persist it until success or final failure.
- Send the same key and the same body on every retry.
- Treat
409in-flight as “wait and retry with the same key,” not a new key. - Do not rotate the key on network errors; that creates duplicates by design.
/v1/charges route — or follow the schema above to wire your own.
Ship the boring contract
Idempotency is not clever caching. It is a written promise: same actor, same key, same body → same outcome. Put that promise in Postgres with a unique constraint, hash the request, and replay completed responses. Your support inbox — and your ledger — will thank you.