0) Problem Restatement
Design a system that lets merchants charge customers through external payment providers (card networks, banks, PayPal). A payment goes through several steps:
- Authorize: ask the card's bank to hold the money (e.g., a hotel holds $200).
- Capture: actually take the held money (maybe days later, when the order ships).
- Refund: give money back.
- Settle: the provider moves the real money to the merchant's bank, usually in daily batches.
The top requirement is correctness: never charge a customer twice, and never lose track of money, even when networks time out and servers crash. Target scale is about 10,000 transactions per second.
Asked at: JPMorgan, OpenAI, Rippling, Salesforce, Visa — 18 candidate reports between Dec 2025 and Aug 2026.1) Requirements
1.1 Functional
- Create a payment, authorize, capture (full or partial), cancel and refund.
- Get payment status.
- Notify merchants of status changes (webhooks).
- Reconcile our records with provider reports every day.
1.2 Non-Functional
- Exactly-once effect: retries must not create double charges.
- Durability and auditability: every change recorded, nothing deleted.
- High availability for authorization, which sits in the checkout path.
- Security: card numbers never stored in plain text (PCI rules).
1.3 Scale Estimates
- 10K transactions/sec at peak, about 200M per day.
- Each payment makes ~5 ledger entries plus state changes → ~50K DB writes/sec. That calls for sharding by merchant or payment ID.
- Provider calls take 200 ms–2 s, and some time out, so we must handle "unknown" results.
1.4 API Design
POST /v1/paymentswith headerIdempotency-Key: 8f1c...and body{ amount: 5000, currency: "USD", payment_method_token, capture: "manual" }→{ payment_id, status: "authorized" }POST /v1/payments/{id}/capture{ amount }POST /v1/payments/{id}/refunds{ amount }GET /v1/payments/{id}
2) High-Level Architecture
2.1 Overview
- Payment API: checks the idempotency key and validates the request.
- Payment Service: runs the payment state machine (a fixed set of states and allowed moves between them).
- Tokenization vault: stores card numbers securely and returns tokens. The rest of the system only sees tokens.
- Provider adapters: one per provider (Visa/Mastercard acquirer, PayPal), with retries and timeouts.
- Ledger: a double-entry record of all money movement.
- Outbox + Kafka: reliably publishes events (for webhooks, analytics, settlement).
- Reconciliation jobs: compare our records with provider settlement files.
2.2 Architecture Diagram
Architecture Diagram
flowchart LR
M["Merchant"] --> API["Payment API - idempotency check"]
API --> PS["Payment Service - state machine"]
PS --> DB[("Payments DB + Outbox")]
PS --> V["Token Vault"]
PS --> PA["Provider Adapters"]
PA --> EXT["Card networks / banks"]
PS --> L[("Ledger - double entry")]
DB -->|"outbox relay"| K[("Kafka")]
K --> WH["Webhook Sender"]
K --> ST["Settlement jobs"]
ST --> REC["Reconciliation vs provider files"]3) Data Model
CREATE TABLE payments (
payment_id UUID PRIMARY KEY,
merchant_id UUID,
amount BIGINT, -- in cents
currency CHAR(3),
status TEXT, -- created, authorizing, authorized, capturing, captured, failed, refunded
provider TEXT,
provider_ref TEXT, -- the provider's ID for this payment
version INT
);
CREATE TABLE idempotency_keys (
merchant_id UUID, key TEXT, request_hash TEXT, response JSONB, created_at TIMESTAMP,
PRIMARY KEY (merchant_id, key)
);
CREATE TABLE ledger_entries ( -- never updated, only inserted
entry_id UUID, payment_id UUID, account TEXT, direction TEXT, -- debit / credit
amount BIGINT, created_at TIMESTAMP
);
CREATE TABLE outbox (event_id UUID, payload JSONB, published BOOLEAN);
4) Key Flows
4.1 Authorize with idempotency
- The merchant sends
POST /paymentswith anIdempotency-Key. - We insert the key into
idempotency_keys. If it already exists, we return the saved response. The client retried, so we must not charge again. - We create the payment in state
authorizingbefore calling the provider. That way, if we crash, we know a call may have been made. - We call the provider, passing our
payment_idas their idempotency key too. - On success, set
authorized, write ledger entries, and in the same DB transaction write an outbox event. Save the response under the idempotency key.
4.2 Capture and settle
Capture moves the payment authorized → captured. Every night, the provider sends a settlement file. A job matches each line to our payments and writes ledger entries for fees and payouts.
5) Deep Dive A — The timeout problem ("unknown outcome")
We call the bank, and the call times out. Did the charge happen? We don't know. We must never just retry blindly with a new request, and never assume it failed.
- Keep the payment in
authorizing(unknown). - Retry with the same provider idempotency key. The provider returns the original result if it already processed it.
- If the provider stays unreachable, a background job keeps asking "what's the status of payment_id X?" until it gets an answer.
- The merchant sees "processing" until we are sure.
This is why every external call carries an idempotency key, and why we record state before the call.
6) Deep Dive B — Ledger, outbox and reconciliation
- Double-entry ledger: every movement is written as two entries that add to zero. For example, debit "customer_receivable" $50 and credit "merchant_payable" $50. If the books don't balance, there's a bug. Entries are append-only, so we never edit history, and corrections are new entries.
- Outbox pattern: writing to the DB and publishing to Kafka are two separate systems, so one can succeed while the other fails. Instead, write the event into an
outboxtable in the same transaction as the state change. A relay process reads the outbox and publishes to Kafka. Events are never lost, and duplicates are handled by consumers using event IDs. - Reconciliation: every day, compare (1) our ledger, (2) provider settlement files, and (3) bank deposits. Any mismatch (a missing capture, a wrong fee) goes to a review queue.
- Sharding: shard payments and ledger by
merchant_idso a payment's records live together and transactions stay on one shard.
7) Trade-offs & Alternatives
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Consistency | Strong (SQL, per-shard transactions) | Money must be exact | Eventual NoSQL: faster, risky for balances |
| Duplicate protection | Idempotency keys at API and provider | Safe retries everywhere | Dedup by amount/time: unreliable |
| Events | Transactional outbox | No lost or phantom events | Dual writes: can diverge |
| Multi-step flows | State machine + saga steps | Clear recovery after crashes | Distributed 2-phase commit: providers don't support it |
8) Common Follow-up Questions
- "How do you add a second provider?" Add an adapter and a router that picks a provider by cost, success rate or card type. Fail over to another provider only when you're sure the first attempt did not succeed.
- "Offline merchants that send payments later?" Accept batches with client-side IDs as idempotency keys, process them asynchronously, and reconcile the results.
- "How do you keep card data safe?" Keep card numbers only in a separate, locked-down vault (small PCI scope), and pass tokens everywhere else.
9) Wrap-Up
Use a state machine that records "in progress" before calling providers, idempotency keys at every step, a double-entry append-only ledger, and a transactional outbox for events. Treat timeouts as "unknown" and resolve them by querying the provider, and reconcile daily with provider files so every cent is accounted for.