awesome-everything RU
↑ Back to the climb

Backend Architecture

Server-side state machine: four states of an idempotency key

Crux When a request arrives with an Idempotency-Key the server moves through four states: new, in-flight, replay, and mismatch. The request fingerprint catches reused keys with changed intent.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 14 min

A payment POST arrives with Idempotency-Key K1. Two seconds later the same key arrives again — but the amount field has changed. Should the server replay the old response, process a new charge, or refuse? Getting this wrong either misses a real change or creates a duplicate charge.

The four states

When a request with Idempotency-Key arrives, the server looks up the key in a deduplification cache (a Postgres table or Redis entry). There are exactly four outcomes:

1. New — no row exists for this key. The server inserts (key, fingerprint, status=in_progress, expires_at) in the same transaction as the business logic, processes the work, then updates the row with (response_body, response_status, status=completed).

2. In-flight — a row exists with status=in_progress. Another request is already processing this key concurrently. The server returns 409 Conflict with {"error": "idempotency key in use"} immediately. The client backs off and retries.

3. Replay — a row exists with status=completed and a stored response. The server returns the cached response body and status code without re-running any business logic. The client gets one confirmation. The customer is charged once.

4. Mismatch — a row exists for this key, but the fingerprint of the incoming request does not match. The client has reused the key with a different intent (different amount, different recipient). The server returns 422 Unprocessable Entity. The client must generate a fresh key for the new operation.

StateConditionServer responseBusiness logic runs?
NewNo row for this key200/201 (after processing)Yes
In-flightRow exists, status=in_progress409 ConflictNo
ReplayRow exists, status=completed, fingerprint matches200 (cached)No
MismatchRow exists, fingerprint differs422 Unprocessable EntityNo

Why the request fingerprint matters

The fingerprint is a hash of the request body (and optionally method, path, and key headers). Stripe hashes the body; payment APIs that route by Stripe-Account include that header too.

Without the fingerprint, a client that accidentally reuses a key for a different amount would silently get back the original response — and the new amount would be lost forever. The fingerprint makes this a hard error (422) instead of silent data loss.

Cost: one SHA-256 per request — microseconds. Cheap insurance.

Where to store the cache

Postgres table (simple, durable):

CREATE TABLE idempotency_keys (
  key          TEXT PRIMARY KEY,
  fingerprint  TEXT NOT NULL,
  response_body JSONB,
  response_status INT,
  status       TEXT NOT NULL DEFAULT 'in_progress',
  expires_at   TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON idempotency_keys (expires_at);

A daily cleanup job deletes rows past expires_at. Stripe v1 held keys for 24 hours; v2 extended to 30 days.

Redis with TTL (higher throughput): SETNX key value EX ttl_seconds — atomic set-if-not-exists. Redis is faster but risks losing entries on async-fsync crash. Payment APIs that hold legal liability keep the authoritative record in Postgres and use Redis as a hot-path read-through cache.

Why this works

Why 409 for in-flight instead of blocking until the first request finishes? Because blocking ties up a server connection for the duration of a potentially slow external API call (tens to hundreds of milliseconds). 409 is cheaper: the client retries after a short backoff, and by then the first request has usually completed and the row is in replay state.

Quiz

A POST to /charge with Idempotency-Key K1 succeeded. The client retries K1 with a different amount field. What should the server return?

Quiz

Why does the server return 409 instead of blocking when it sees a key with status=in_progress?

Quiz

A client retries Idempotency-Key K1 with the same body 26 hours after the first attempt (Stripe v1 TTL = 24 hours). What does the server do?

Recall before you leave
  1. 01
    Why does the request fingerprint (body hash) matter alongside the idempotency key, and what breaks without it?
  2. 02
    Trace a POST /charge that succeeds, then is retried with the same key and body. What happens at each step?
  3. 03
    When should the server store the idempotency key in Postgres vs Redis, and what is the durability tradeoff?
Recap

Every idempotency-key lookup resolves to one of four states: new (insert and process), in-flight (409 to trigger backoff), replay (return the cached response), or mismatch (422 because the fingerprint changed). The request fingerprint — a SHA-256 of the body — is what makes it safe to replay: without it, a reused key with a different amount would silently return the wrong response. Stripe v1 holds keys for 24 hours; v2 extended to 30 days for compliance. Past expiry, the row is gone and the server treats the request as new.

Connected lessons
appears again in179
Continue the climb ↑Retry strategies: backoff, jitter, and thundering herd
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.