Concept · Reliability

Idempotency

01

Why this matters

A user clicks "Pay." The request hits your server, charges their card, but the response packet is lost in the network. The user's app doesn't see success, retries. Now you've charged them twice. Classic distributed-systems horror story.

Idempotency is the property that repeating an operation yields the same result as doing it once. If the payment POST is idempotent, the retry is harmless — the server sees "already processed, return the original response." The user is charged once, sees success, life is good.

Every production-grade API must handle retries safely. Idempotency is how.

02

Which HTTP methods are already idempotent

The HTTP spec says:

  • GET, HEAD, OPTIONS — idempotent. Reads don't change state.
  • PUT, DELETE — idempotent. "Set user status to active" repeated still yields active. "Delete user 5" repeated still yields deleted.
  • POST — NOT idempotent by default. "Create new order" repeated creates two orders.
  • PATCH — NOT idempotent by default. Depends on operation (set vs increment).

In practice, designers often use POST for operations that should be idempotent (create user with specific ID, charge a card with a specific intent). The fix: idempotency keys.

03

Idempotency keys — the industry pattern

Client generates a unique key (UUID) and sends it in a header: Idempotency-Key: abc123. Server stores: {key → response}.

  • First request with key abc123 → process normally, store the response, return it.
  • Retry with same key → server sees the key exists, returns the stored response without re-processing.
  • Key TTL: usually 24 hours — long enough to cover any reasonable retry window.

Stripe pioneered this in their public API. Clients send a key; the second retry returns the same charge. Most payment APIs and many non-payment APIs (GitHub Apps, Twilio) now support the pattern.

Idempotency-key enforcement (Postgres)
-- client sends Idempotency-Key header
-- server upserts into an idempotent-requests table:
CREATE TABLE idempotency_keys (
  key          TEXT PRIMARY KEY,
  user_id      BIGINT NOT NULL,
  request_hash TEXT NOT NULL,        -- SHA-256 of body
  response     JSONB,
  status_code  INT,
  created_at   TIMESTAMPTZ DEFAULT now(),
  expires_at   TIMESTAMPTZ NOT NULL  -- e.g. now() + INTERVAL '24 hours'
);

-- On request:
-- 1. SELECT existing row FOR UPDATE
-- 2. If hit + request_hash matches → return stored response (200)
-- 3. If hit + request_hash differs → 409 Conflict
-- 4. If miss → INSERT placeholder, execute, UPDATE with response
Idempotency-Key FlowMermaid
sequenceDiagram participant C as Client participant S as Server participant DB as DB C->>S: POST /charge
Idempotency-Key: abc123 S->>DB: check idem_keys DB-->>S: not found S->>DB: INSERT charge, INSERT idem_key(abc123, response) S-->>C: 200 OK Note over C: Network error · retry C->>S: POST /charge
Idempotency-Key: abc123 S->>DB: check idem_keys DB-->>S: found · response stored S-->>C: 200 OK (same response)
04

Three ways to make operations idempotent

PatternMechanismBest for
Deterministic resource IDClient generates ID (UUID), server uses upsertCreate operations where client can pre-mint ID
Idempotency key tableStore (key → response); match + return on retryComplex operations with side effects
Conditional updateIf-Match / version number check — only updates if current version matchesOptimistic concurrency updates
05

Deep dive — the concurrent-retry race

Two retries race. Both use the same key. The naive flow is:

  1. Request A: SELECT idempotency_keys WHERE key = 'abc123' → not found.
  2. Request B: SELECT idempotency_keys WHERE key = 'abc123' → also not found (A hasn't committed).
  3. Both process the charge. Card is charged twice. Both try to INSERT the key — one fails, but the damage is done.

Fix: INSERT first, then process. Start with INSERT ... ON CONFLICT DO NOTHING in the idempotency table. If the INSERT fails, another request is processing; wait for the result. If it succeeds, you own the key — proceed.

Or stronger: acquire a distributed lock keyed by idempotency key for the duration of the operation. Release after committing. Second retry blocks until first finishes, then reads the stored response.

Stripe's public blog post on this is the definitive read. Short version: "insert the key as a 'pending' row with a unique constraint; finalize with the response when done." Pending rows become your distributed lock.

06

What NOT to make idempotent

  • Truly non-idempotent operations. "Send a push notification" — if client retries, they want a second notification sent. Don't suppress.
  • Real-time messaging. Chat app retries? Deliver both; let the client dedupe by message ID.
  • Anything where the client controls the semantics. If the client explicitly wants "process again," they should use a different key.

The rule: idempotency is a tool, not a reflex. Apply it where double-execution would cause harm. Do not apply it where double-execution is the intent.

07

Real-world

Stripe

Idempotency-Key header

All mutating endpoints accept the header. Response cached for 24 hours. The reference implementation.

AWS SQS FIFO

MessageDeduplicationId

Within a 5-minute window, messages with the same dedupe ID are dropped. Exactly-once semantics inside SQS FIFO.

PayPal / Twilio

X-Idempotency-Key

Same pattern. Twilio gives a 24-hour window; PayPal 24 hours for payments.

Database unique constraints

The low-tech version

Put a unique constraint on orders(external_order_id). INSERT retries fail the constraint; catch + treat as idempotent.

08

Used in problems

Payment gateway problems always use idempotency keys. Ticketmaster uses them to prevent double-booking on retries. Notification system pairs idempotency keys with at-least-once delivery for dedup.

Next up