open atlas
↑ Back to track
Backend Architecture BE · 05 · 02

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

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.

BE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

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

By the end of this lesson you will be able to trace any idempotency-key lookup to its correct outcome — and know exactly which HTTP status to expect and why.

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

When you see a 422 on a payment retry, your first instinct should be to check whether the key was reused across different request bodies — not whether the server is broken.

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. Now when you see a 422 on a retry, check key reuse first; when you see 409, the client is racing itself and needs a short backoff before trying again.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons
appears again in205

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

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.