← System design

Financial & Trading

Design Payment Gateway

A payment gateway sits between a merchant's checkout button and the card networks. Its job sounds small: take an amount and a card, get a yes or no from the bank, move the money later. What makes it one of the hardest interview problems is that every network call can time out after the money has already moved, and the system must still know, with certainty, whether a customer was charged once, twice or not at all.
Core: idempotency keys + payment state machine + ledger25M payments a dayZero double chargesDaily reconciliation to the cent

01

The problem

01

The problem

Build the service that merchants integrate to accept card payments online. A merchant's server creates a payment, the customer enters card details in a hosted field, and the gateway asks a card processor or acquiring bank to authorise the amount. Later the merchant captures the funds, perhaps issues a refund, and the gateway settles money into the merchant's bank account, minus fees.

Most system design problems tolerate a little staleness or an occasional duplicate. This one does not. Correctness beats latency, and money must balance. A duplicate charge is a support ticket and a chargeback; a lost charge is revenue the merchant never sees; and a ledger that does not match the bank is an audit finding.

Uncertain outcomes

A request to the processor can time out with no reply. The charge may or may not have happened, and you must find out before retrying.

External truth

The bank's settlement file, not your database, is the final word on what moved. You reconcile against it every day.

Card data is toxic

Raw card numbers bring heavy compliance obligations. The less of your system that ever sees one, the better.

Long-lived state

A payment lives for months: authorised, captured, partially refunded, disputed. Every step must be recorded and auditable.

02

What the gateway must do

02

What the gateway must do

Functional
  • Merchants create a payment for an amount and currency and confirm it with a tokenised card.
  • Support authorise-then-capture (hotels, marketplaces) and immediate capture (digital goods).
  • Full and partial refunds, voids of uncaptured authorisations, and dispute handling.
  • Signed webhooks to the merchant for every state change.
  • Daily payouts to merchants net of fees, with reports that tie every payout to its payments.
  • Route to more than one processor and fail over between them.
Non-Functional
  • No double charges, ever, even when merchants retry, workers crash or processors time out.
  • Every cent is accounted for in a double-entry ledger that balances at all times.
  • Payment API availability of 99.99%; p99 for confirmation under 2 seconds, most of it the bank's time.
  • Card data handled within a minimal PCI DSS scope.
  • Full audit history: nothing is updated in place without a record of what it was before.
Ask about money movement

Clarify whether the gateway is the merchant of record holding funds before payout, or only passes requests to a processor that settles directly with merchants. Holding funds means you also need a ledger, payout scheduling and reserves. Most interviewers want that version, so confirm it before you draw.

03

Sizing the load

03

Sizing the load

Assume 25 million payments a day across 400,000 merchants, with checkout traffic peaking at about eight times average during a large sale event. Each payment produces several ledger entries and events over its lifetime.

QuantityWorkingResult
Average payment rate25M / 86,400 sabout 290 / s
Peak payment rate8x during a saleabout 2,300 / s
API calls per paymentcreate, confirm, capture, retrieve, plus retriesabout 6
Peak API load2,300 x 6about 14K requests / s
Ledger entries25M x about 6 lines (auth, capture, fee, payout)150M rows a day
Payment rows25M x 365 x 2 KB with historyabout 18 TB a year
Webhooks25M x 3 events75M a day, about 900 / s
Processor latencynetwork authorisation round trip300 ms to 1.5 s
In-flight authorisations2,300 / s x 1.5 s worst caseabout 3,500 concurrent calls
Daily volume25M x $42 averageabout $1B a day
Throughput is modest; stakes are not

A few thousand writes a second fits a well-sharded relational database comfortably. The design effort goes into correctness under failure, not raw scale. With a billion dollars a day flowing, a 0.01 percent duplicate rate is a hundred thousand dollars of wrong charges daily.

04

API design

04

API design

POST/v1/paymentsCreate a payment
// Authorization: Bearer sk_live_... (merchant secret key, server side only) // Idempotency-Key: cart-55102-attempt-1 { "amount": 249900, // minor units: 2,499.00 "currency": "inr", "capture": "manual", "reference": "order_55102" } // 201 Created { "id": "pay_3NcX81", "status": "requires_payment_method", "client_secret": "pay_3NcX81_secret_..." }
POST/v1/payments/{id}/confirmAttach a card token and authorise
{ "payment_method": "tok_visa_9f2c" } // token from the hosted card field, never a raw number // 200 OK { "id": "pay_3NcX81", "status": "authorized", // or requires_action (3-D Secure), declined, processing "amount_capturable": 249900 }
POST/v1/payments/{id}/captureCollect all or part of the authorised amount
{ "amount": 199900 } // partial capture releases the remainder
POST/v1/refundsRefund a captured payment
{ "payment": "pay_3NcX81", "amount": 50000, "reason": "item_returned" } // Rejected with 409 if total refunds would exceed the captured amount
POSThttps://merchant.example/hooks/paymentsWebhook we send to the merchant
// Signature: t=1790851200,v1=hmac_sha256(secret, t + "." + body) { "id": "evt_77Ld", "type": "payment.captured", "data": { "payment": "pay_3NcX81", "amount": 199900 } } // Retried with backoff for 3 days until the merchant returns 2xx
Amounts are integers

Always store and transmit money in the currency's smallest unit as an integer. Floating point cannot represent 0.10 exactly, and rounding errors in a ledger are the kind of bug you find in an audit, months later.

05

Architecture overview

05

Architecture overview

Payment path and the money-tracking sideDiagram
flowchart LR
	B["Customer browser"] --> HF["Hosted card field"]
	HF --> V[("Card vault, PCI zone")]
	M["Merchant server"] --> API["Payments API"]
	API --> IK[("Idempotency records")]
	API --> PS["Payment service: state machine"]
	PS --> PDB[("Payments DB with outbox")]
	PS --> RT["Processor router"]
	RT --> V
	RT --> P1["Processor A"]
	RT --> P2["Processor B"]
	PDB -. "outbox relay" .-> EV[["Event log"]]
	EV --> LG[("Double-entry ledger")]
	EV --> WH["Webhook sender"]
	REC["Reconciliation jobs"] --> LG

Card details never touch the merchant's servers or most of ours. The customer types them into a hosted field served from our domain, which posts directly to the vault, a small, separately audited service that encrypts the number and returns a token. Everything else in the system works with tokens. Only the processor router, inside the same compliance zone, asks the vault to detokenise when it builds a request to a processor.

The payment service owns a state machine per payment. Every transition, such as authorizing to authorized, is a database write that checks the current state, so two concurrent requests cannot both move the same payment. In the same transaction, the service writes an event row to an outbox table. A relay reads the outbox and publishes events to a log, which the ledger, webhooks and analytics consume. This avoids the classic bug where the database commits but the event publish fails, or the reverse.

Reconciliation jobs compare three views of each day: our payment records, our ledger, and the processors' settlement files. Any mismatch becomes a case for an operator.

ComponentOwnsStore
Card vaultencrypted card numbers, tokensHSM-backed keys, isolated DB
Payment servicepayment state, attempts, outboxPostgres sharded by merchant
Idempotency recordskey to stored response, 24 hsame DB as payments
Ledgerimmutable debit and credit linesappend-only relational tables
Processor routerrouting rules, health, retriesstateless, config in DB
Webhook senderdelivery attempts per endpointqueue plus attempts table

06

Deep dive: charging exactly once

06

Deep dive: charging exactly once

The one property that matters

A customer is charged exactly once for each payment the merchant intended. Networks cannot give you exactly-once delivery, so you build it from at-least-once retries plus idempotency at every boundary, and a way to resolve outcomes you did not see.

Idempotency at the API

Merchants retry. Their HTTP client times out at 10 seconds while our processor call takes 11, or their load balancer replays a request. Every mutating call therefore carries an idempotency key chosen by the merchant. On first sight of a key, the API inserts a record with status started and a hash of the request body, inside the same database as the payment. When the work finishes, it stores the response on that record.

A second request with the same key gets one of three answers. If the record is finished, return the stored response byte for byte. If it is still in progress, return 409 and tell the client to retry shortly. If the body hash differs, return an error, because the merchant is reusing a key for a different payment, which is a bug on their side worth surfacing loudly.

Confirming a payment when the processor may time outDiagram
flowchart TD
	R["Confirm request with idempotency key"] --> K{"Key seen before?"}
	K -->|finished| SAME["Return stored response"]
	K -->|new| ST["Move payment to authorizing, write attempt id"]
	ST --> CALL["Call processor with attempt id as its reference"]
	CALL --> RES{"Reply?"}
	RES -->|approved| AU["Move to authorized, outbox event"]
	RES -->|declined| DE["Move to declined, outbox event"]
	RES -->|timeout| UNK["Stay in authorizing, mark unknown"]
	UNK --> LOOK["Resolver queries processor by attempt id"]
	LOOK --> AU
	LOOK --> DE
	AU --> SAVE["Store response on idempotency record"]
	DE --> SAVE

The timeout problem

The dangerous moment is when we send an authorisation and hear nothing back. The bank may have approved it, declined it or never received it. Retrying blindly risks a second hold on the customer's card; giving up risks failing a payment that actually succeeded.

The answer is to give every attempt its own ID, written to our database before the processor call, and pass it to the processor as the merchant reference. If the call times out, the payment stays in authorizing with an unknown outcome. The API tells the merchant the payment is processing. A resolver job then asks the processor for the status of that reference, with backoff, until it gets a definite answer, and completes the transition. Most processors also accept the reference as their own idempotency key, so a retry with the same attempt ID cannot create a second authorisation.

If the processor still cannot say after a long window, the system voids the reference to release any hold and fails the payment. Better to ask the customer to pay again than to risk charging twice.

A state machine with guarded transitions

Each payment moves through a small set of states: requires_payment_method, authorizing, authorized, captured, partially_refunded, refunded, declined, voided. Every update is a conditional write such as "set status to captured where id is X and status is authorized and version is 7". If two workers race, one of them updates zero rows and backs off. There are no in-place edits without a recorded transition, which makes the history auditable by construction.

Double-entry ledger

The payments table tells you the state of each payment. The ledger tells you where the money is. Every movement is recorded as at least two lines that sum to zero: a capture of 2,499.00 debits "card network receivable" and credits "merchant balance", while the fee debits the merchant and credits "gateway revenue". Lines are append only; a refund is new lines in the opposite direction, never an edit. Because every entry balances, the sum of all accounts is always zero, and any non-zero total means a bug you can detect immediately.

The ledger is written from the event log, keyed by event ID with a unique constraint, so replaying events never double-books.

Reconciliation closes the loop

Each morning, processors deliver settlement files listing what they actually moved. A job matches each line to our records by reference and amount. Three kinds of mismatch appear: lines the processor has but we do not (often the result of a timeout we never resolved), lines we have but they do not, and amount differences from currency conversion or fees. Each one becomes a case with an owner. Reconciliation is how you find the bugs that idempotency missed.

A strong interview answer

"Merchants send an idempotency key that maps to a stored response. Each processor attempt gets an ID that is saved before the call and sent as the processor reference, so timeouts are resolved by asking, not by retrying blind. Payments move through guarded state transitions with an outbox in the same transaction. A double-entry, append-only ledger tracks money, and daily reconciliation against settlement files catches whatever slipped through."

07

Decisions and what they cost

07

Decisions and what they cost

Chosen
Relational database with strong consistency

Transactions, unique constraints and conditional updates are exactly the tools a state machine and an idempotency table need. Shard by merchant to scale writes.

Correctness first
Rejected
Eventually consistent NoSQL for payment state

Scales effortlessly, but two replicas accepting different transitions for the same payment is the precise failure you cannot allow. Fine for analytics copies, not for the record.

Wrong guarantees
Chosen
Transactional outbox

The event row commits with the state change or not at all. A relay publishes it later, so downstream consumers see every transition exactly as the database does, at the cost of a little extra delay.

No lost events
Rejected
Write to the DB, then publish to the queue

If the process dies between the two, either the ledger misses a capture or it books one that was rolled back. Both show up only at reconciliation.

Dual write gap
Chosen
Several processors behind a router

Fail over when one processor degrades, route by card type or region for better approval rates, and negotiate fees. Each processor needs its own integration and reconciliation.

Resilience and cost
Alternative
A single processor

Much simpler to build and reconcile. Its outage is your outage, and you have no leverage on pricing.

Fine early on
Chosen
Fast fraud score inline, deep review after

A model scores each payment in a few tens of milliseconds and blocks or challenges the risky ones with 3-D Secure. Slower checks run after authorisation and can void before capture.

Balanced
Rejected
All fraud checks synchronous

Catches more before the bank call, but adds hundreds of milliseconds to every checkout and turns any fraud service outage into a payment outage.

Too slow, too coupled

08

Failure modes

08

Failure modes

⏳
The processor times out after charging

The bank approved the authorisation but the reply was lost. A naive retry places a second hold on the customer's card.

Persist the attempt ID before calling, send it as the processor reference, keep the payment in a pending state on timeout, and resolve it by querying the processor before any retry.
🔁
A merchant retries with a new key

The merchant's code generates a fresh idempotency key on every retry, so our dedup never fires and the customer is charged twice.

Document that keys must be stable per intended payment, and add a soft guard: flag or block a second payment with the same merchant reference, card and amount within a short window.
🧾
Ledger and settlement file disagree

The morning file shows 14 captures we have no record of, all from one hour when a database failover happened.

Reconciliation raises cases automatically; operators match each line to an unresolved attempt and the resolver completes the transition, with ledger entries written from the resulting events.
📉
A processor degrades during a sale

Approval rates on one processor drop from 92 percent to 60 percent and latency triples, while traffic is at its peak.

Track approval rate and latency per processor in real time and shift new payments to the healthy one. Never re-send an already attempted payment to a second processor without first voiding the first attempt.
📬
A merchant's webhook endpoint is down

Events fail for a day. When the endpoint returns, it receives thousands at once, some out of order.

Retry with exponential backoff for several days, include event IDs and timestamps so the merchant can deduplicate and order them, and offer an events API to fetch anything missed.
🔐
Card numbers leak into logs

A debug log line prints a full request body containing card data, which drags the whole logging stack into compliance scope.

Keep raw card data inside the vault only, tokenise at the edge, and scrub logs with pattern detectors that redact anything shaped like a card number.

09

Mistakes to avoid

⚠

Mistakes to avoid

🚫
Retrying a timed-out authorisation immediately

The first attempt may have succeeded. A retry creates a second hold or a second charge on the same card.

✓ Better: mark the outcome unknown and query the processor by attempt reference before doing anything else.
🚫
Storing money as floating point

Binary floats cannot represent most decimal fractions, so sums drift by fractions of a cent and ledgers stop balancing.

✓ Better: integers in minor units, with currency stored alongside every amount.
🚫
Updating a balance column in place

There is no history, concurrent updates can lose writes, and you cannot explain to an auditor how the number got there.

✓ Better: append-only ledger lines; balances are sums or cached projections of them.
🚫
Letting raw card numbers flow through every service

Every service, log and backup that sees a card number falls into compliance scope and becomes a breach risk.

✓ Better: hosted fields and a vault, so the rest of the system only handles tokens.

10

How to handle the interview

09

How to handle the interview

1

State the invariant first. "No customer is charged twice, and every cent balances." Then tie each component back to that sentence.

2

Walk through the timeout case slowly. This is the question interviewers are waiting for. Attempt ID before the call, unknown state, resolve by asking.

3

Explain the idempotency record precisely. Where it is stored, what happens on a concurrent duplicate, and why the body hash matters.

4

Use a state machine with conditional writes. Name the states and show one guarded transition.

5

Draw the ledger as double entry. One capture example with two lines makes the concept concrete in seconds.

6

Mention the outbox. It shows you know the dual write problem and how to avoid it without distributed transactions.

7

Shrink PCI scope. Hosted fields, a vault and tokens. Interviewers notice when candidates keep card data out of their main services.

8

End with reconciliation. Admitting that software has bugs and showing how you would catch them daily is a mature finish.

11

Growing the design in stages

11

Growing the design in stages

Stage 1: one merchant, one processor
A thin wrapper over a processor SDK

The app calls a processor directly and stores a payment row. Card data never touches your servers because the processor's hosted page collects it.

Stage 2: many merchants
Idempotency and a state machine

Merchant retries cause the first double charges, so idempotency keys and guarded transitions arrive, along with signed webhooks.

Stage 3: holding funds
Ledger, payouts and reconciliation

Once you hold money before paying out, a double-entry ledger, payout schedules and daily matching against settlement files become mandatory.

Stage 4: high volume
Vault, outbox and multiple processors

A dedicated vault shrinks compliance scope, the outbox removes dual writes, and a router spreads traffic across processors. The single-processor dependency is gone.

Stage 5: global
Local methods and regional routing

Local payment methods such as UPI, bank transfers and wallets join cards, routing optimises approval rates per country, and data residency rules shape where records live.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.