HTTP Idempotency-Key for safe retries

By Abinashi Singh · ~8 min read · Practical Node + Postgres

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

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

Kits: Webhook-side uniqueness is covered by HookQueue ($19). Protect the API edge with RateGuard ($19) — Postgres windows, no Redis required. The live IdemKey kit ($19) ships Express middleware, Postgres claim/replay, and a demo /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.

← Back to Singh DevTools · Related: Webhook idempotency →