Payment system¶
At a glance
Priority: P0 · Complexity: ⅘ · Est. time: 3 h · Phase: 4 · Prereqs: API design, Reliability patterns, Consistency models, Databases, Security You're done when: you can design a payment flow that never double-charges and never loses money under retries, timeouts and partial failures — with idempotency keys, a payment state machine, a double-entry ledger, and reconciliation.
Payments invert the usual interview: QPS is low, but correctness is absolute. The interviewer wants to hear how you handle the unknown outcome — "we called the card network and timed out; did the customer get charged?"
Problem statement¶
Design the payment backend for an e-commerce/marketplace platform (think checkout at Amazon, Airbnb, or a logistics booking platform): accept payments via external payment service providers (PSPs: Stripe, Adyen, …), track money movement internally, pay out to sellers, handle refunds and chargebacks, and reconcile with bank/PSP statements.
Clarifying questions to ask¶
| Question | Why | Assumption |
|---|---|---|
| Do we hold card data or use a PSP? | PCI DSS scope | PSP with tokenisation (hosted fields) — we never see PANs |
| Pay-in only or marketplace with payouts? | Ledger complexity | Marketplace: pay-in, hold, pay-out to sellers, platform fee |
| Currencies / FX? | Ledger design | Multi-currency, amounts in minor units; FX at pay-out |
| Volume? | Sizing | 10 M payments/day, peak 20x on sale days |
| Synchronous confirmation required? | Flow design | Yes for card auth; capture/pay-out async |
| Regulatory? | Audit, retention | 7–10 yr immutable records, SCA/3DS in EU |
Functional & non-functional requirements¶
Functional: create payment intent; authorise/capture/void/refund; 3DS challenge flow; webhooks from PSP; wallet/balance per seller; payouts; ledger; reconciliation; reporting.
Non-functional: exactly-once effect (no double charge, no lost payment); strong consistency for balances; full auditability (immutable, append-only); availability 99.99% for checkout (every minute down is lost revenue); p99 checkout API < 1 s excluding PSP latency; PCI DSS scope minimised.
Back-of-envelope estimation¶
Payments: 10 M/day ≈ 116 /s avg → sale-day peak ×20 ≈ 2,300 /s
Ledger: each payment ≈ 4–8 ledger entries (auth hold, capture, fee, seller credit, payout...)
→ ~60 M entries/day ≈ 700 /s avg, ~14 k/s peak
Storage: 60 M × ~300 B ≈ 18 GB/day ≈ 6.5 TB/yr; 10 yr retention ≈ 65 TB (fine for sharded Postgres)
PSP latency: auth 300 ms–2 s p50, tails to 10 s+ → timeouts are routine, not exceptional
Webhooks: ~3 per payment ≈ 30 M/day ≈ 350 /s avg
Say it: the numbers are small; a well-sharded relational database handles them. The design is about state machines, idempotency, and reconciliation.
API design¶
POST /v1/payment_intents
Idempotency-Key: checkout-8f2c...
{ "order_id": "o_123", "amount": 12999, "currency": "EUR", "customer_id": "c_9", "payment_method_token": "pm_tok_..." }
→ 201 { "id": "pi_1", "status": "requires_action", "next_action": { "type": "3ds_redirect", "url": "..." } }
POST /v1/payment_intents/{id}/confirm Idempotency-Key: ...
POST /v1/payment_intents/{id}/capture { "amount": 12999 }
POST /v1/refunds { "payment_intent": "pi_1", "amount": 5000 } Idempotency-Key: ...
POST /internal/webhooks/psp # signed; deduped by PSP event id
Idempotency semantics (Stripe's model): the server stores (key, request_hash) → response; a replay with the same key returns the stored response; same key with a different body → 422. Keys expire after ~24 h.
Data model¶
| Table | Key | Notes |
|---|---|---|
payment_intents |
id |
order_id, amount, currency, status (state machine), psp, psp_reference, version |
payment_attempts |
id |
intent_id, attempt_no, psp request id, outcome, raw response |
idempotency_keys |
(merchant, key) |
request hash, locked_at, response code/body, recovery point |
ledger_entries |
id (append-only) |
txn_id, account_id, amount (signed, minor units), currency, created_at |
ledger_transactions |
id |
description, idempotency ref; invariant: entries sum to 0 per currency |
accounts |
id |
type (customer_receivable, seller_payable, platform_revenue, psp_clearing, …) |
outbox |
id |
events to publish (payment.succeeded) in same DB txn |
Double-entry: every movement debits one account and credits another; the sum of all entries is always zero. Balances are derived (materialised with a version for fast reads).
High-level design¶
flowchart LR
C[Checkout client] --> API[Payments API]
API --> IK[(Idempotency keys)]
API --> PSM[Payment state machine]
PSM --> DB[(Payments DB + outbox)]
PSM --> PSPA[PSP adapter]
PSPA --> PSP1[PSP A]
PSPA --> PSP2[PSP B]
PSP1 -. webhooks .-> WH[Webhook ingester]
WH --> PSM
DB --> OB[Outbox relay] --> K[[Event bus]]
K --> LED[Ledger service]
LED --> LDB[(Ledger: append-only)]
K --> ORD[Order service]
REC[Reconciliation jobs] --> LDB
REC --> STMT[PSP / bank settlement files]
LED --> PO[Payout service] --> BANK[Bank rails]
Deep dives¶
1. Idempotency end-to-end¶
Every hop needs its own key: client→API (Idempotency-Key), API→PSP (pass a deterministic key, e.g. pi_1:attempt_2, which PSPs like Stripe honour), PSP→us (webhook event_id dedupe), service→ledger (txn_id unique constraint). Implementation per Brandur's pattern: an idempotency row with recovery points — atomic phases committed locally so a crashed request resumes from the last phase instead of starting over. Airbnb's Orpheus library formalised the same idea and notes a trap: read the idempotency record from the primary, not a lagging replica, or retries can double-execute.
2. Handling unknown outcomes (the timeout problem)¶
| Option | Pros | Cons |
|---|---|---|
| Treat timeout as failure, let user retry | Simple | Double charge if PSP actually succeeded |
| Retry immediately with same PSP idempotency key | Safe if PSP dedupes | Retry storms; PSP key TTL limits |
Mark UNKNOWN, query PSP status / await webhook, then resolve |
Correct | Latency; user sees "processing" |
Decision: state machine with explicit PENDING_UNKNOWN. On timeout: respond "processing" to the client, schedule status polling with backoff and rely on webhooks; a reconciliation sweeper resolves any intent stuck > N minutes. Never create a new PSP attempt until the previous attempt's outcome is known. This single idea is the heart of the interview.
stateDiagram-v2
[*] --> created
created --> requires_action: 3DS needed
created --> authorizing
requires_action --> authorizing
authorizing --> authorized
authorizing --> failed
authorizing --> unknown: timeout
unknown --> authorized: PSP status / webhook
unknown --> failed
authorized --> captured
authorized --> voided
captured --> refunded: partial or full
captured --> disputed
3. Ledger design¶
| Option | Pros | Cons |
|---|---|---|
| Mutable balance column | Simple | No audit, race conditions, can't explain history |
| Double-entry append-only in relational DB | Auditable, invariants via transactions | Hot accounts (platform revenue) contend |
| Purpose-built ledger DB (TigerBeetle) | Very high throughput, built-in double-entry | New operational tech; integration |
| Event-sourced ledger on a log | Replayable | Harder to enforce invariants synchronously |
Decision: double-entry, append-only in Postgres, sharded by account; each ledger transaction written atomically with a sum-to-zero check; hot accounts split into N sub-accounts (sum on read). Corrections are reversing entries, never updates. Stripe's Ledger (Feb 2024 post) processes ~5 B events/day and treats explainability of every money movement as the product.
4. Reconciliation¶
Three-way match daily (and intraday for high volume): internal ledger ↔ PSP reports ↔ bank settlement files. Mismatch categories: missing internally (webhook lost), missing at PSP (our false success), amount/currency/fee differences, timing (settlement T+1/T+2). Output: exception queue for finance ops with auto-resolution rules. Reconciliation is the safety net that makes at-least-once + idempotency trustworthy.
Scaling & bottlenecks¶
- 2,300 payments/s peak is comfortable for a sharded relational DB (shard by merchant or intent ID).
- Hot rows: platform revenue/fee accounts → sub-account splitting or batched aggregation.
- PSP rate limits and latency → connection pools, bulkheads per PSP, circuit breakers.
- Webhook bursts after PSP recovery → queue then process idempotently.
Failure modes & reliability¶
| Failure | Mitigation |
|---|---|
| PSP timeout / unknown | unknown state, status query, webhook, sweeper |
| Our service crashes mid-request | Idempotency recovery points; outbox guarantees events are published after commit |
| Duplicate webhooks, out-of-order | Dedupe by event id; state machine rejects illegal transitions; use PSP object state, not event order |
| PSP outage | Route to secondary PSP for new payments (tokens may be PSP-specific → network tokens help) |
| Ledger/payments divergence | Continuous consistency checks + reconciliation |
| Region failure | Active-passive with synchronous replication for payments DB (RPO 0 is often required) — see multi-region |
Security & multi-tenancy¶
- PCI DSS: hosted fields/tokenisation keep PANs off your servers → drastically smaller scope (SAQ A vs full assessment).
- SCA/3DS2 in the EU; fraud scoring before auth (rules + ML, e.g. velocity checks).
- Webhook signature verification; mTLS to internal services; secrets in HSM/KMS.
- Least privilege: only the ledger service writes ledger tables; refunds above thresholds need 4-eyes approval.
- Multi-tenant marketplaces: per-merchant accounts, KYC/AML checks before payouts, data segregation for audits.
How the design changes at 10x / in an AI-era variant¶
10x: ledger throughput becomes the constraint (~140 k entries/s peak) → purpose-built ledger DB or batched posting, sharded accounts, and asynchronous balance materialisation; multiple PSPs with smart routing by cost/auth rate.
AI-era variant: (1) agentic commerce — AI agents initiating payments on behalf of users (card-network agent tokens, delegated mandates with spending limits, merchant-side agent protocols emerging in 2025–26): design scoped, revocable payment credentials with per-agent limits, explicit user confirmation above thresholds, and audit trails attributing each payment to an agent + user; (2) LLM-assisted reconciliation exception triage and dispute evidence drafting — humans approve, models suggest; never let a model post ledger entries; (3) ML fraud models remain the core AI use and need drift monitoring.
What a Staff-level answer adds (vs senior)¶
- Names the unknown-outcome problem first and designs the state machine around it.
- Treats reconciliation and finance operations as part of the system, with owners and SLAs.
- Discusses PCI scope reduction as an architecture decision, and multi-PSP strategy (cost, auth rates, resilience, lock-in via tokens).
- Designs for auditors and regulators: immutable records, reversing entries, retention, segregation of duties.
- Knows where not to use microservices: keep the payment state machine and ledger writes transactionally close; use sagas + outbox at boundaries (see architecture: sagas & outbox).
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| Stripe — Designing robust and predictable APIs with idempotency | article | The canonical explanation of idempotency keys | intermediate | free |
| Brandur — Implementing Stripe-like Idempotency Keys in Postgres |
article | Recovery points, atomic phases — production-grade detail | advanced | free |
| Airbnb — Avoiding double payments in a distributed payments system | article | Orpheus library; replica-lag trap | advanced | free |
| Stripe — Ledger: tracking and validating money movement | article | Ledger + data quality at 5 B events/day | advanced | free |
| Modern Treasury — Accounting for Developers |
article | Clearest intro to double-entry for engineers | intermediate | free |
| TigerBeetle docs |
docs | Purpose-built ledger DB; great reading on debit/credit invariants | advanced | free |
| Stripe API — Idempotent requests | docs | Exact semantics (key TTL, body mismatch) | intermediate | free |
| Hello Interview — Payment system | article | Interview-paced walkthrough | intermediate | free |
Follow-up questions¶
L2 — Apply¶
Q1. The client retries POST /payment_intents with the same Idempotency-Key while the first request is still in flight. What happens?
Answer
The first request inserted the idempotency row with locked_at set (unique constraint on (merchant, key)). The second sees a locked, incomplete row → return 409 Conflict (retry later) rather than executing concurrently. After the first completes, replays get the stored response. If the first crashed, the lock times out and a retry resumes from the last recovery point.
Q2. Write the ledger entries for a €100 marketplace sale with a 10% platform fee, then a €30 partial refund.
Answer
Sale (captured): debit psp_clearing 100, credit seller_payable 90, credit platform_revenue 10. Refund €30 (fee pro-rated): debit seller_payable 27, debit platform_revenue 3, credit psp_clearing 30. Each transaction sums to zero. When the PSP settles to the bank: debit bank_cash, credit psp_clearing (net of PSP fees, which go to a psp_fees expense account).
Q3. A PSP webhook says 'charge.succeeded' but your intent is 'failed' (you timed out and marked it failed). What now?
Answer
This is exactly why you shouldn't mark timeouts as failed — use unknown. If it happened anyway: the PSP is the source of truth for card outcome; transition via a compensating path: either honour the order (if still possible) or immediately refund, with ledger entries for both charge and refund. Alert — each occurrence is a customer-visible defect. Fix the state machine so failed is only set on a definitive PSP decline.
L3 — Design & trade-offs¶
Q4. Microservices for payments: which boundaries, and where do you refuse to split?
Answer
Reasonable services: checkout/payment orchestration, PSP adapters, ledger, payouts, fraud, reconciliation. Refuse to split the payment intent state and its idempotency record and outbox — they must commit in one local transaction. Ledger transaction entries must be atomic within the ledger service. Cross-service flows (payment succeeded → ledger → order fulfilment) use outbox + idempotent consumers (saga), not distributed 2PC.
Q5. Single PSP vs multi-PSP routing?
Answer
Single: simpler, better pricing tiers, one integration. Multi: resilience to PSP outages, higher authorisation rates via routing (by card BIN/region), negotiating leverage. Costs: tokens are often PSP-scoped (use network tokens or a vault to be portable), reconciliation per PSP, more complex disputes. Decision threshold: once volume is large enough that a 1% auth-rate improvement or a 1-hour outage pays for the integration and team.
Q6. Balance reads for sellers are slow because you sum millions of entries. Fix without losing correctness.
Answer
Maintain a materialised balance per account updated in the same transaction as entries (with optimistic version), or periodic checkpoints (balance at entry N) + sum of entries after N. For hot accounts, split into sub-accounts. Verify materialised balances against full sums nightly. Keep the ledger append-only as source of truth.
L4 — Staff-level ambiguity¶
Q7. Finance reports a €2 M discrepancy between the ledger and bank settlements last quarter. You lead the investigation and remediation.
Answer
Classify the gap via three-way reconciliation by day/PSP/currency: timing vs fees vs missing transactions vs FX. Typical causes: unrecorded PSP fees, chargebacks not ingested, lost webhooks, FX rounding. Remediation: correcting entries (reversals, never edits) approved by finance; systemic: daily automated reconciliation with exception SLAs, ingestion of all PSP report types, alarms on unreconciled amount thresholds. Communicate with finance leadership in money terms with a timeline; this is also a controls issue for auditors.
Q8. The product wants AI shopping agents to complete purchases autonomously for users. What must the payment platform provide?
Answer
Delegated, scoped credentials (per-agent tokens with merchant/amount/time limits, revocable), strong customer authentication at mandate creation and step-up above limits, attribution of each payment to (user, agent, mandate) for disputes, fraud models retrained on agent behaviour patterns, and clear liability rules in terms. Architecturally: a mandate service in front of the payment intent API, with policy checks and full audit. Start with low limits and a narrow merchant set.
Q9. You inherit a payment system where balances are mutable columns updated in place. Plan the migration to a double-entry ledger.
Answer
Phase 1: start writing double-entry entries in parallel (shadow ledger) for every new movement; compare derived balances to mutable balances daily; investigate diffs. Phase 2: backfill opening balances as entries at a cutover date (audited). Phase 3: switch reads to ledger-derived balances per cohort. Phase 4: make mutable columns read-only, then remove. Throughout, keep reconciliation running and involve finance/audit early — their sign-off is the real exit criterion.
Checklist¶
- I can draw the payment state machine including
unknown - I can explain idempotency at every hop (client, PSP, webhook, ledger)
- I can write double-entry postings for sale, fee, refund, payout
- I can describe three-way reconciliation and what it catches
- I answered all L3 questions out loud in < 3 min each