System design explainer
The interview question behind every "Pay" button: the user double-clicks, the network retries, the app times out — and you must never charge twice. How do you build a system where retries are safe?
You cannot have exactly-once delivery in a distributed system — but you can have exactly-once effects. The trick is idempotency keys: the client tags each payment intent with a unique key, the server stores the key alongside the result, and any retry carrying the same key gets the stored result instead of a second charge. Retries become safe. Double-charges become impossible by construction. That one mechanism is the entire answer.
The analogy: paying online is like mailing a check that might get delivered twice. You can't stop the post office from duplicating the envelope — so instead you write a unique serial number on every check, and the bank cashes each serial number exactly once, no matter how many copies arrive. The idempotency key is the serial number.
"Retries cause double charges, so a payment system should avoid retrying." Backwards. Networks fail — retries are inevitable, so the system must make them safe instead of forbidding them. The rule is: never retry without an idempotency key; with a key, retry aggressively. The key is what makes retries safe, not the absence of retries.
| Assumption | Value |
|---|---|
| Charges per day (mid-size processor) | 20,000,000 |
| Charge QPS | 20M ÷ 86,400 ≈ 231 avg · peak 3× ≈ 700 QPS |
| Idempotency keys retained | 72 hours → 60M keys × ~200 bytes ≈ 12 GB in Redis — trivial |
| Ledger growth | 20M × 500 bytes ≈ 10 GB/day · ~3.6 TB/year, append-only |
| Retry rate | ~2% of requests retried → dedupe saves ~400k charges/day from double-processing |
| PSP latency | 300–800ms per authorization — dominates our p99, not our code |
The line to say out loud: "The dedupe store is 12 GB — the cheapest insurance in the architecture. The ledger is append-only because money movement is a fact, and facts don't get updated."
Long enough to cover every plausible retry — client timeouts, webhook redelivery, and the finance team re-running yesterday's batch. 24–72 hours covers operations; some systems keep keys for months because storage is cheap and a duplicate charge is expensive. The real answer: keep them as long as your longest retry window, then age them out with a TTL. "Forever" is defensible if you can afford the storage.
flowchart TB
CL["Client app"]
GW["API gateway"]
IDF["Idempotency filter
Redis: key → stored response"]
PS["Payment service"]
PSP["Card processor
(Stripe / Adyen)"]
LED[("Ledger
Postgres, append-only")]
WH["Webhook handler"]
REC["Reconciler
daily PSP settlement check"]
CL --> GW
GW --> IDF
IDF -->|"key unseen"| PS
IDF -->|"key seen"| CL
PS --> PSP
PS --> LED
PSP -. "async events" .-> WH
WH --> LED
REC --> PSP
REC --> LED
The idempotency filter sits in front of the payment service — a duplicate never reaches the charging logic at all. And note the belt-and-suspenders: a UNIQUE constraint on the key column in Postgres means even if Redis loses a key, the database refuses the second charge.
sequenceDiagram
autonumber
participant C as Client
participant F as Idempotency filter
participant P as Payment service
participant S as Card processor
participant L as Ledger
C->>F: POST /charges {key: k1, $19.99}
F->>F: k1 unseen
F->>P: forward
P->>S: authorize $19.99
S-->>P: approved (ch_8f2k)
P->>L: append charge + key k1
P->>F: store k1 → receipt
F-->>C: 200 charged (ch_8f2k)
Note over C,S: network retry — same key k1
C->>F: POST /charges {key: k1, $19.99}
F->>F: k1 seen → return stored receipt
F-->>C: 200 charged (ch_8f2k) — no new charge
flowchart TB
A["Client creates payment intent
generates UUID key"] --> B["Send request with
Idempotency-Key header"]
B --> C{"key in store?"}
C -->|"yes"| D["Return stored response
200, no side effects"]
C -->|"no"| E["Reserve key: processing"]
E --> F["Charge via processor"]
F --> G["Append to ledger"]
G --> H["Store key → full response
TTL 72h"]
H --> I["Return 200 to client"]
F -->|"timeout, unknown outcome"| J["Reconcile: query processor
by key before deciding"]
J --> G
The scariest moment in payments: you sent the charge to the processor and the response never came back. Did it charge or not? You cannot guess — guessing wrong double-charges or loses money. The correct move: query the processor for the charge by your idempotency key (processors support this), and only then decide. This is the distributed-systems "two generals" problem wearing a suit, and idempotency keys are the practical truce.
The client — because the client owns the intent ("the user clicked Pay for cart #1234"). A good key is deterministic per intent, e.g. user_88:cart_1234:attempt_1 or a UUID generated once when the checkout page loads. If the server generated it, a retry couldn't present the same key and the whole scheme collapses. Interviewers love this question — it's a one-sentence trap for memorized answers.
POST /v1/charges
Idempotency-Key: 7f3a9c2e-… # required, client-generated
{"amount": 1999, "currency": "usd", "payment_method": "pm_123"}
→ 200 OK {"charge_id": "ch_8f2k", "status": "succeeded", "amount": 1999}
# same key again → identical 200, no new charge
POST /v1/charges/{id}/refunds {"amount": 1999}
GET /v1/charges/ch_8f2k
Data model: Charge {id, idempotency_key UNIQUE, amount, currency, status, processor_ref, created_at} — the unique constraint is the last line of defense. IdempotencyRecord {key, response_body, status_code, expires_at} in Redis for the fast path. LedgerEntry {id, charge_id, type: charge/refund, amount}, append-only, immutable.
| Decision | Why | Cost |
|---|---|---|
| Client-generated keys (required) | Only the client knows the intent | Clients must implement it correctly — SDKs help |
| Redis fast path + DB unique constraint | Speed plus a backstop that can't be evicted | Two systems to keep consistent; DB is source of truth |
| Store the full response, not just "seen" | Retry returns the identical receipt | More bytes per key — still trivial |
| 72h key TTL | Covers every realistic retry window | Edge-case duplicates after expiry — accepted, logged, refundable |
| Processor as the money mover | PCI scope stays tiny | You're bounded by their latency and API |
UNIQUE constraint rejects the duplicate write. Defense in depth.API: Node or Go service, Idempotency-Key header required on all mutating endpoints. Dedupe: Redis with 72h TTL in front, Postgres UNIQUE(idempotency_key) as the backstop. Money: Stripe as the processor — don't build card handling. Ledger: append-only Postgres table; daily reconciliation job against Stripe. Webhooks: outbox pattern, idempotent handlers. Two engineers, one month, and the demo below is the core loop.
A real idempotency filter running in this page. Pay creates a payment intent with a fresh key. Retry resends the same key — like a network retry or a double-click. Triple-submit fires three concurrent requests with one key. Watch the request log: retries return the stored receipt, and the ledger below records exactly one charge per key.
The "no key" button charged you twice for one cart — that's what the entire payments industry looked like before idempotency keys. Notice the failure needed no malice: just two requests and no serial number. Keys don't prevent duplicate requests; they make duplicate requests harmless. That distinction is the whole design.