System design explainer

Design a payment system

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?

The takeaway, up front

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.

1 · Requirements

What are we actually building?

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.

Functional

  • Charge a card: amount, currency, payment method
  • Safe retries — client or network may resend any request
  • Refunds and partial refunds
  • Query charge status; webhooks for async events
  • Full audit trail of every money movement

Non-functional

  • Correctness: never double-charge — this is a never-event, not a metric
  • Latency: p99 under 2s (bounded by the card processor)
  • Durability: the ledger is append-only and never loses a write
  • Scale: hundreds of charges/sec at peak, keys retained for days
  • Compliance: card data stays with the processor (PCI scope minimized)

🚫 Common misconception

"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.

2 · Back-of-the-envelope

Capacity math

AssumptionValue
Charges per day (mid-size processor)20,000,000
Charge QPS20M ÷ 86,400 ≈ 231 avg · peak 3× ≈ 700 QPS
Idempotency keys retained72 hours → 60M keys × ~200 bytes ≈ 12 GB in Redis — trivial
Ledger growth20M × 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 latency300–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."

Go deeper: how long do you keep a key?

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.

3 · Architecture

The system, end to end

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.

4 · Component deep-dives

First charge vs retry

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

The key lifecycle

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
Go deeper: the "unknown outcome" problem

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.

Go deeper: who generates the key?

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.

5 · API + data model

The contract

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.

6 · Trade-offs

What you give up, on purpose

DecisionWhyCost
Client-generated keys (required)Only the client knows the intentClients must implement it correctly — SDKs help
Redis fast path + DB unique constraintSpeed plus a backstop that can't be evictedTwo systems to keep consistent; DB is source of truth
Store the full response, not just "seen"Retry returns the identical receiptMore bytes per key — still trivial
72h key TTLCovers every realistic retry windowEdge-case duplicates after expiry — accepted, logged, refundable
Processor as the money moverPCI scope stays tinyYou're bounded by their latency and API
7 · Failure modes

What breaks, and what saves you

8 · What I'd actually build

Opinionated, concrete, shippable

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.

9 · Interview tips

How to run the room

  1. Say "exactly-once effects, not exactly-once delivery" early. It shows you know the theory and the practice.
  2. Draw the idempotency filter before the payment service in your diagram — placement is the insight.
  3. Name the DB unique constraint as defense in depth. Interviewers light up at backstops.
  4. Have the processor-timeout story ready — it's the hardest question and the most realistic failure.
  5. State who generates the key and why (the client owns the intent). It's a classic follow-up.
  6. End with the never-event framing: "double-charge isn't a metric we monitor, it's a property we guarantee."
10 · Interactive widget

Idempotency demo — double-click all you want

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.

Cart total: $19.99 Last key: —
Requests: 0 Charged: 0 Deduped: 0 Ledger total: $0.00
Request log
Ledger — money actually moved

No charges yet.

The server logic is 6 lines: if (store.has(key)) return store.get(key) — check the key before touching money. Everything else in this article is making that check fast, durable, and impossible to skip.

Go deeper: try the bad path, then read this

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.

v2026.10.03-01