Avoid double charges
The network can lose your response but not your side effect. The client retries; the charge already happened. Idempotency is the machinery that makes "do this once" survive a world where you can never be sure whether the last attempt landed.
POST /pay, waits 3 s, sees no response, and gives up. Did the charge happen? You cannot know from the client's side. The request may have died before reaching you; or it reached you, charged the card, and the response died on the way back. DDIA Ch. 8 calls this the network's unbounded delay: a timeout is a guess, not a fact. Because the client cannot tell "didn't happen" from "happened but I didn't hear," any safe client must retry — and any safe server must therefore make the retry a no-op that returns the original answer. That contract is idempotency, and this case is the anchor for its mechanics.The retry storm, with numbers
Make the failure concrete before designing anything. The client's HTTP timeout is 3 s. The payment provider (PSP) actually settles the authorization at 4 s — slow, but successful. Here is the window that mints duplicate charges:
The window is open for tsettle − ttimeout = 4 − 3 = 1 s on every request whose true latency exceeds the client's patience. At scale this is not a corner case: if 0.5% of 2,000 payments/s straddle the timeout, that is 2000 · 0.005 = 10 double-charge attempts per second — 864k/day of liability if unguarded. The fix is not "make the PSP faster" (you don't control it) — it is to make the second call recognize itself as a repeat and short-circuit. That recognition needs a stable name for the operation that is identical across retries: the idempotency key.
Clarify the contract
Treat the prompt as a product contract before a box diagram. The system must:
- Make a retry with the same key a no-op that returns the original response — not a second charge.
- Guarantee one business intent → at most one successful charge, even under concurrent retries on different servers.
- Dedup the PSP's webhooks — the provider also retries its callbacks at-least-once.
- Expose a clean state machine:
pending → succeeded | failed → refunded. - Support audit / dispute investigation for the full chargeback window.
And explicitly what it need not do: it need not guarantee the charge happens exactly once at the PSP on the first try — that is impossible across a network (DDIA Ch. 9). It guarantees the observable effect is exactly-once: at-least-once delivery + dedup = effectively-once. We make the operation safe to repeat, then repeat freely.
Sizing: the key TTL is the whole storage problem
The only real sizing question is how long you keep idempotency records, because everything downstream multiplies off it. The naive instinct — "expire keys after an hour" — is a bug. A retry can arrive late; worse, a customer can dispute a charge months later, and support must replay exactly what happened. So:
TTL = max( PSP settlement window , card dispute / chargeback window ) ≈ 90–180 days
Card-network chargeback windows run 120 days (Visa/Mastercard) up to 180. Take 180 days. Now storage is mechanical: at 2,000 payments/s and a record of ~1 KB (key, request hash, status, serialized response, timestamps):
31 TB of dedup metadata is the price of correctness — and the trade-off table's first row exists precisely because someone always proposes a 1-hour TTL to shrink it. That choice trades 31 TB for the inability to honor a 90-day dispute, which costs far more in fraud and support than disk. The TTL is set by the business window, not the engineer's comfort. (Hot path stays in a fast store keyed for lookup; cold records age to cheaper storage but must remain queryable for the dispute window.)
Data model & API
The data model is the architecture here. The key insight: the idempotency record and the payment intent are two different things — the key dedups requests; the intent tracks the money.
| Table | Key columns | Purpose |
|---|---|---|
idempotency_key | key (PK, UNIQUE), request_hash, status {in_progress, completed}, response_blob, intent_id, created_at | Dedup requests. The UNIQUE constraint on key is load-bearing — it is the atomicity primitive. |
payment_intent | id (PK), amount, currency, status {pending, succeeded, failed, refunded}, psp_charge_id | The money's state machine. One intent per business intent. |
provider_event | psp_event_id (PK, UNIQUE) | Dedup inbound webhooks — same insert-if-absent trick on a different key. |
outbox | id, intent_id, payload, sent_at (null = pending) | Decouples the PSP call from the DB transaction (see deep dive). |
| API | Contract |
|---|---|
POST /pay + header Idempotency-Key | Charge. Same key returns the same result; same key + different body → 422. |
GET /payment_intent/{id} | Read current state — the recovery path after a client timeout. |
POST /provider/webhook | PSP settlement callback; deduped by psp_event_id. |
Linearized design
Follow one POST /pay through the system, then watch the duplicate try collapse on it.
- 1. Fingerprint. Compute
request_hash = H(method, path, body). The client suppliesIdempotency-Key; the hash binds the key to this exact request. - 2. Insert-if-absent. Attempt
INSERT INTO idempotency_key(key, request_hash, status='in_progress'). The UNIQUE constraint makes exactly one of N concurrent attempts win. - 3a. Insert succeeds → first-ever request. Create the
payment_intent(pending) and anoutboxrow in the same transaction. Commit. A separate worker drains the outbox and calls the PSP. - 3b. Insert fails (key exists) → a repeat. Read the existing row. If
status=completed, return the storedresponse_blobverbatim. Ifin_progress, return409/ "retry shortly" (the first attempt is still running). If the storedrequest_hash≠ this one, return422— the client reused a key with a different body. - 4. PSP result lands (via outbox worker response or webhook). Transition the intent
pending → succeeded, store the PSP response inresponse_blob, flip the key tocompleted. - 5. Webhook dedup. The PSP also calls
/provider/webhook, possibly twice.INSERT psp_event_id; on conflict, ack and drop. Same mechanic, different key.
The bottleneck this exposes: step 2's insert is the single point every request funnels through, and it must be atomic. Get that wrong and the whole edifice leaks duplicates — which is the first deep dive.
Deep dives
1. The simultaneous-miss race — why read-then-write is wrong
The trap implementation: read the key, if absent then write it. Now the sharpest senior scenario: two requests with the same key arrive simultaneously on two different servers, and both miss the cache. Server A reads "absent," Server B reads "absent," both proceed to charge the PSP, both write the key. Two charges. This is DDIA Ch. 7's classic write-skew / lost-update — a read-modify-write with a gap between read and write under concurrency.
The fix is to never read-then-write. Make the existence check and the claim a single atomic operation at the place the data lives:
- Relational: a
UNIQUEconstraint onkeyplusINSERT … ON CONFLICT DO NOTHING. The database serializes the two inserts; exactly one returns "inserted," the loser sees the conflict and falls into the read-existing branch. - Redis:
SET key value NX EX ttl— set-if-not-exists in one round trip.NXis the atomicity.
The loser must not charge — it polls the winner's row (the in_progress state) until it flips to completed, then returns that response. The principle generalizes well beyond payments and is the same one C35's neighbors lean on: a distributed dedup is only correct if the check-and-claim is atomic where the data lives. This is also why the idempotency key is not "client politeness" — it is the only stable name two independent servers can agree on without talking to each other.
2. Client-generated key vs. server-issued intent — and the mismatch trap
Who mints the key? Two models:
- Client-generated (Stripe-style): client sends a UUID in
Idempotency-Key. Simple, one round trip — but the client can misuse it. - Server-issued intent: client first
POST /payment_intentto get anintent_id, then confirms it. Two round trips, but the server controls the namespace.
The client-generated model has a dangerous failure: a buggy client reuses one key for two genuinely different payments — same key, $10 then $1000. A naive server sees the key, returns the first result, and silently swallows the $1000 charge (or, worse, applies the $10 response to it). This is why step 1 stores the request_hash. On a repeat, compare hashes: equal → it is a true retry, return the cached response; unequal → reject with 422, because "same key, different request" is definitionally a client bug, and guessing which one they meant is how you double-charge or lose a charge. The hash turns the key from a blind cache lookup into a verified identity claim.
3. The outbox — why you don't call the PSP inside the transaction
Step 3a created the intent and an outbox row in one transaction, then a worker calls the PSP. Why not just call the PSP directly while the row is being written? Because the PSP call is a side effect outside the database's transaction, and you get the dual-write problem: if you charge first then the DB commit fails, you have a charge with no record (and the retry double-charges); if you commit first then crash before charging, you have a record with no charge. There is no ordering that is safe, because two systems can't share one atomic commit.
The transactional outbox (DDIA Ch. 11 — the outbox as log-based change capture; Ch. 9's exactly-once = at-least-once + dedup) breaks the deadlock: the intent and the "please charge" message are written to the same database in one transaction, so they commit or roll back together. A separate worker reads unsent outbox rows and calls the PSP — at-least-once (it may retry after a crash), which is exactly why the PSP call also carries an idempotency key so the PSP itself dedups. The DB is the single source of truth; the outbox is the durable, replayable intent-to-act. This is the messaging-side counterpart to C27's delivery-semantics: C27 owns the concept (why exactly-once is at-least-once + dedup); this case owns the concrete request/payment key implementation.
Trade-offs
| Choice | Buys | Costs | Choose when |
|---|---|---|---|
| Long TTL (dispute window) vs short TTL | honors chargebacks & late retries; full audit | ~31 TB of key storage | always for payments — TTL = dispute window, not engineer comfort |
| Client-generated key vs server-issued intent | one round trip, simple API | client misuse → needs request-hash guard | public APIs; pair with hash mismatch → 422 |
| Outbox worker vs synchronous PSP call | no dual-write; survives crashes; replayable | extra hop, eventual completion latency | any external side effect inside a DB txn |
| Atomic INSERT-if-absent vs read-then-write | correct under concurrent same-key retries | none worth mentioning | always — read-then-write is simply a bug |
The sharpest one is the last: read-then-write looks correct in every single-threaded test and leaks duplicates only under the exact concurrency the retry storm guarantees. It is the difference between code that passes review and code that survives production — and the entire reason the UNIQUE constraint, not application logic, owns the dedup.
Failure modes
| Failure | Mitigation |
|---|---|
| Same key, different amount | Compare stored request_hash; reject mismatch with 422. |
| Two same-key requests, both miss | Atomic INSERT … ON CONFLICT / SET NX; loser reads winner's row. |
| Duplicate PSP webhook | Dedup on psp_event_id UNIQUE; make transitions idempotent. |
| Charge succeeds after user saw "failed" | Expose pending; reconcile via GET /payment_intent and webhook truth. |
| Crash between charge and DB write | Outbox + PSP-side idempotency key; worker retries safely. |
| Repeated refund | Separate refund idempotency key + refund state machine. |
Interview Q&A
- Two requests with the same key arrive simultaneously on two servers — both miss the cache. Now what? (senior answer) Read-then-write loses here — both read "absent," both charge. The fix is an atomic check-and-claim where the data lives: a UNIQUE constraint with
INSERT … ON CONFLICT DO NOTHING, or RedisSET NX. Exactly one insert wins and charges; the loser detects the conflict and polls the winner'sin_progressrow until it returns the stored response. The dedup must be owned by the store, not application logic. - A client times out at 3 s but the PSP succeeds at 4 s. What breaks and how do you stop it? (senior answer) The client can't tell "didn't happen" from "happened, response lost," so it retries — and within the 4−3=1 s window the second call double-charges. The idempotency key makes the retry recognize itself: same key → return the original result, no second charge. You also forward the key to the PSP so it dedups too.
- How long do you keep idempotency keys? (senior answer)
max(settlement window, dispute window)— typically 90–180 days, set by the card-network chargeback window, not by storage convenience. At 2,000 req/s × 180 days that's ~31 B keys ≈ 31 TB; a 1-hour TTL would "save" that storage at the cost of being unable to honor a dispute or a late retry, which is far more expensive. - Why not just call the PSP inside the transaction that writes the intent? (senior answer) Dual-write problem: the PSP is outside the DB's atomic commit, so any ordering leaves a window where you have a charge with no record or a record with no charge. Use a transactional outbox — intent + "charge" message committed together — and a worker that calls the PSP at-least-once with an idempotency key. The DB stays the single source of truth.
- Client reuses one key for a $10 and a $1000 payment. What happens? (senior answer) Without a guard you'd return the $10 result for the $1000 request and lose money. Store a
request_hashwith the key; on a repeat, equal hash → true retry (return cached), unequal →422. "Same key, different body" is a client bug you must surface, never silently resolve. - Isn't exactly-once impossible across a network? (senior answer) Exactly-once delivery is impossible (DDIA Ch. 9), but exactly-once effect isn't: at-least-once delivery + idempotent dedup = effectively-once. We embrace retries everywhere and make every operation safe to repeat.
- How do you dedup the provider's webhooks? (senior answer) Same mechanic as request keys: the PSP sends a stable
psp_event_id;INSERTit with a UNIQUE constraint, ack-and-drop on conflict, and make the state transition idempotent (a secondpending→succeededis a no-op). The webhook is at-least-once just like the client. - Where should the idempotency key live — DB or Redis? (senior answer) Whichever co-locates the key with the data you must mutate atomically. For payments the key lives with the intent in the relational store so the claim and the write share one transaction; Redis
SET NXis fine as a fast pre-check but the durable, dispute-window record belongs in the database.
Related lessons
This case is the anchor for idempotency-key and outbox mechanics — insert-if-absent, key TTL, request-hash. For the concept of delivery semantics and why effectively-once is at-least-once + dedup, see C27 (delivery semantics); it owns the messaging concept, this case owns the request/payment-key implementation. The double-entry side of money movement — what the charge debits and credits — lives in C33 (double-entry ledger); C32 (orchestration) and C34 (reconciliation) both link here for the dedup key. Foundation: lesson 12 (idempotency, outbox, sagas) for the primitives, and lesson 13 for the backoff-with-jitter that keeps the retry storm from becoming a thundering herd.
SET NX, never read-then-write), guarded by a stored request-hash so a reused key can't mask a different charge, retained for the dispute window (~180 days ≈ 31 TB), and fed to the PSP through a transactional outbox so the external charge never escapes the DB's atomic commit. At-least-once + dedup = effectively-once. That is the entire trick — and every payment case in this set links here for it.