SysPad › Interview practice › Design a payment system
Design a payment system
The interviewer says: "Design a payment system." Shoppers on a commerce platform pay merchants for orders by card.
We charge the card through a PSP, a payment service provider such as Stripe, which asks the card network and the bank.
Every payment moves money in a ledger. The PSP owes us the net, its fee is our cost, we owe the merchant, and we earn our own fee.
Shoppers and merchants read payment status, receipts and balances. The PSP also tells us each outcome by calling us back.
We pay merchants out daily, and we check our ledger against the PSP's report daily.
We charge a card at most once per order, and we lose no payment the bank approved.
Our side of a checkout adds under 50 ms to the PSP's call. On the peak day, 99.95% of attempts must reach a PSP that can process them.
Disputes, subscriptions and lending are out of scope.
How we work it: Interview ready means we prove each card is charged once and the books balance, and we know which wall breaks first on Black Friday.
Do the numbers before the boxes
An interviewer gives a scale, not a load. If we draw before we do the math, we cannot say why any box has its size.
We size for a commerce platform about Shopify's size: $292.3 billion of sales in 2024.
We take $108.56 as the average payment, Shopify's 2024 Black Friday average cart, for the whole year. That is our assumption.
Payments a year: $292.3 billion / $108.56 = 2.69 billion. A day: 2.69 billion / 365 = 7.38 million.
A second on average: 7,380,000 / 86,400 = 85.4.
We take a peak factor of 2.2: the busiest hour carries 2.2 times the average. 85.4 x 2.2 = 188, rounded up to 200 payments a second.
We assume about 1 read per payment: a receipt, an order status, or a merchant checking a balance. That is 200 reads a second.
We measure load in requests per second: 200 + 200 = 400. Payments are half of it.
Black Friday: Shopify's 2024 peak was $4.6 million of sales a minute. $4,600,000 / $108.56 = 42,373 payments a minute, or 706 a second.
We round that to 700 payments and 700 reads: an entry of 1,400, or 700 / 200 = 3.5 times the ordinary peak.
Shopify's sales grew 24% in 2024. So next year's Black Friday is about 700 x 1.24 = 870 payments a second.
Check in: Close the page and derive 400 requests per second from $292.3 billion a year. Then derive the Black Friday 1,400.
Show an answer
$292.3B / $108.56 = 2.69B payments a year; / 365 = 7.38M a day; / 86,400 = 85.4 a second.
x 2.2 = 188, rounded up to 200 payments a second. Plus 200 reads: 400.
Black Friday: $4.6M a minute / $108.56 = 42,373 a minute, 706 a second, rounded to 700. Plus 700 reads: 1,400, which is 3.5 times 400.
Create, call the PSP, record
A payment moves real money. A shopper must never be charged twice, and we must never lose a payment the bank approved.
A checkout can hold several payment attempts: a declined card, then a second card. Each attempt gets its own idempotency key.
An idempotency key is a new ID the client makes for one attempt. It sends the same ID again on every retry of that attempt.
An attempt is three steps. First, create: insert the attempt, keyed by its idempotency key, with the status "processing".
A checkout may have only one attempt that is processing, unknown or succeeded. A partial unique index on the checkout enforces that.
Second, call the PSP, with the attempt ID as the PSP's own idempotency key. The PSP creates and confirms the payment, and the buyer waits.
Third, record: in one transaction, move the status forward and write the ledger entries.
We create before we call on purpose. If we crash after the call, the "processing" row tells a sweeper to ask the PSP what happened.
We assume a PSP call takes about 1 second, because it asks the card network and the bank that issued the card. No PSP publishes a figure.
Each copy of the Payment API is a task with one vCPU, a virtual processor. A request costs about 1.2 ms of CPU, our estimate.
With async I/O, a call waiting on the PSP holds no thread. So one task serves 1,000 / 1.2 = 833 requests a second.
A synchronous stack holds a worker for each open call. At the Black Friday peak, 700 payments a second x 1 second = about 700 workers.
The API uses auto-scaling, keeping each task near 70% busy. 400 / (833 x 0.7) = 0.7, so its floor of 2 tasks runs, at 24%.
80% of payments go to the primary PSP, Stripe, and 20% to a backup PSP. That share keeps the backup warm and tested.
Stripe's defaults are 100 calls a second per account and 25 per endpoint. A platform this size has a contract: we assume 1,500 a second.
On Black Friday the primary's share is 0.8 x 700 = 560 a second, so 1,500 is 2.7 times it.
At 1 second a call, 560 calls are open at once. So the contract also covers 1,500 calls open, against a default of 50.
The backup's contract is 300 a second, about twice its Black Friday share of 0.2 x 700 = 140.
The trade-off:
The buyer waits a second for the bank. We cannot shorten that, so we must never add to it.
Check in: Say why the attempt is created before the PSP call, and not after it.
Show an answer
If we crash after the PSP call, the "processing" row tells a sweeper to ask the PSP what happened, by the same attempt ID.
Created after the call, a crash would leave a charged card with no record of it.
Three writes an attempt, all on the writer
Every attempt writes, and a write can go to only one machine. So the writer, not the PSP, sets how far this design goes.
The database is Aurora PostgreSQL: one writer and one read replica, or reader. Writes go to the writer, and reads to the reader.
A webhook is the PSP calling us back with the outcome. A receiver checks its signature, puts it on a queue, and answers 200 at once.
A worker reads the queue, skips event IDs it has seen, and moves the status forward if it is still processing or unknown.
So an attempt makes 3 writes: create, record, and the webhook's update. Whichever of the last two moves the status writes the ledger.
We assume about 2 ms of CPU a write: a few index inserts and log records. Halve it, and every database wall doubles.
A db.r6g.xlarge has 4 vCPUs, so the writer takes 4 x 1,000 / 2 = 2,000 writes a second.
At 200 payments a second, 200 x 3 = 600 writes use 30% of the writer. The reader takes the 200 reads: 10%.
Stage 1 runs this design in SysPad at 400 requests per second. Each card is a part of the system, and each arrow carries requests.
Read the four tiles. "First to saturate" reads "Nothing at this load". Its note names the Payments database, at 30%.
A part saturates when it gets more requests than it can serve.
The latency tile shows 1.00s. It shows the slowest path, which here is the PSP call the buyer waits for.
The load balancer takes 1.5 ms: 1 ms of routing and 0.5 ms of TLS, the encryption. Then 1.2 ms in the API and 1,000 ms at the PSP.
The cost tile shows $1,400/mo, and the database is 70% of it.
$80 of it is egress: data sent out of AWS to the internet. Here those are our answers to shoppers and merchants, about 1 KB each.
Every cost tile prices the peak rate as if it ran all month. Load balancer, queue and egress charges follow traffic, and the average rate is the peak / 2.2.
So an average month of Stage 1 costs about $1,287. Comparisons between stages hold either way.
The writer takes 2,000 writes a second, which is 667 payments. With their reads, that is an entry of 667 x 2 = about 1,333.
Press 1,500. The Payments database saturates: 750 payments x 3 = 2,250 writes a second, against 2,000.
Press 3,200. The Backup PSP saturates too: 0.1 x 3,200 = 320 calls a second, against its 300.
The trade-off:
Every attempt writes three times to one machine. A reader adds no write capacity, so the writer is the wall.
PSP fees are a share of each payment, set by contract. They are not on this bill.
Stage 1: Create, call the PSP, record: 400 a second
- Load: 400 req/sec
- First to saturate: Nothing at this load
- Estimated cost: $1,400/mo
Check in: Which part runs out first as load grows, and at what load? Then name the next wall.
Show an answer
The writer, at an entry of about 1,333 a second: 667 payments x 3 writes = 2,000, its limit.
The backup PSP is next, at an entry of 3,000: 0.1 x 3,000 = 300, its contract.
Charge once, even when every call repeats
The interviewer asks: The shopper's network drops, and the app sends Pay again. How do you make sure the card is charged once?
Every call in this design can happen twice. The shopper retries, our call to the PSP times out, and the PSP sends a webhook twice.
Delivery exactly once is not possible across a network. Every step here is delivered at least once.
So we aim for exactly-once effects: every step may repeat, and every write is keyed so a repeat changes nothing.
This is a question about keys and order, not about load. So we reason about it by hand, and no stage goes with this beat.
The order of events matters. Step 1: the attempt is created with the status "processing", before the PSP is called.
Step 2: a duplicate arrives while the attempt is in flight, because the shopper pressed Pay again. It finds "processing" and gets 202, processing, with Retry-After 1.
The page then polls the attempt. A duplicate never starts a second PSP call.
Step 3: our own call to the PSP times out. We retry first, with the same attempt ID, at 1, 2 and 4 seconds, inside an 8-second budget.
If the first call ran, the PSP returns its result for that key. Stripe keeps keys at least 24 hours.
Step 4: still no answer after 8 seconds. We mark the attempt "unknown", tell the shopper we are confirming, and a sweeper asks the PSP every minute.
"Unknown" is not "failed". The card may have been charged, so we never charge it again until we know.
Step 5: only once the outcome is recorded does a repeat with the same key get the stored result: 200, succeeded or failed.
The call and the webhook can race to record the outcome. Each moves the status forward only if it is still processing or unknown.
That is a compare-and-set. The path that changes 1 row writes the ledger entries in the same transaction. The other changes 0 rows and writes nothing.
As a backstop, ledger entries are unique on attempt and entry type, so even a bug cannot post them twice.
The webhook can also arrive twice. A processed-events table, keyed by PSP and event ID, rejects the copy.
Events arrive in any order, so a status only moves forward. "Succeeded" never goes back.
A worker that crashes mid-event sees it again. The dedupe and the forward-only update make the rerun change nothing.
After 5 tries, the queue moves the event to a dead-letter queue, a side queue for failures.
Step through the figure to follow one attempt through its repeats.
The trade-off:
Every write carries a key, and an attempt can sit in "unknown" until the sweeper settles it. A reused key with a different body gets 409.
One checkout through a retry
- The attempt is created as processing, with its own key, before the PSP is called.
- The PSP charges the card. Its answer is lost on the way back.
- A duplicate finds the attempt still processing. It gets 202 and polls, and never starts a second PSP call.
- We retry the PSP with the same attempt ID. It returns the first charge, so the card is charged once.
- Now a repeat gets the stored result. A second webhook copy changes nothing.
Check in: The shopper presses Pay twice, and our PSP call times out. Say what each repeat gets, in order, and why the card is charged once.
Show an answer
The attempt is created as processing. The duplicate press finds it in flight and gets 202 processing, then polls. It never starts a second PSP call.
Our own PSP call retries with the same attempt ID at 1, 2 and 4 seconds. After 8 seconds the attempt is unknown, and a sweeper asks the PSP.
Only once the outcome is recorded does a repeat get the stored 200.
The call and the webhook race with a forward-only compare-and-set: the winner writes the ledger, and a unique entry key backs it. Duplicate webhooks hit processed_events.
Double entry, and checking it against the PSP
The interviewer asks: How do you know where every dollar went?
An attempt status says what happened to one order. It cannot tell finance who owes whom, or prove that nothing went missing.
This too is a question about records, not about load, so we work it by hand.
We use double entry: every money movement writes rows whose debits equal their credits.
A $108.56 payment pays the PSP a fee of 2.9% x $108.56 + $0.30 = $3.45, at Stripe's US list price. That fee is our expense.
We charge the merchant our own platform fee, an illustrative 3.4% + $0.30 = $3.99.
Debit PSP receivable 10,511 cents, the net the PSP will settle. Debit PSP fee expense 345.
Credit merchant payable 10,457, the gross less our fee. Credit platform fee revenue 399.
Debits: 10,511 + 345 = 10,856. Credits: 10,457 + 399 = 10,856. Our margin is 399 - 345 = 54 cents.
At settlement, the PSP pays us the net: debit cash 10,511, and credit PSP receivable 10,511.
We write these entries at capture. Digital goods capture at checkout, so this page's record step writes them. Shipped goods capture at shipment.
Amounts are whole cents, never floats, and each currency has its own balance. Ledger rows are insert only.
Reconciliation: we match the PSP's settlement report against our ledger, by PSP reference. We match the net amount, not the gross.
Every line is matched or flagged. In the PSP only means a lost record. In the ledger only means an unknown that failed.
The third flag is a different amount or fee. A fee difference is trued up with an adjusting pair of entries.
The most common mismatch is timing, not money. The PSP's day may end at a different hour, and settlement lags capture by days.
So we match across a rolling window of about 3 days, and flag only what is still unmatched after it.
A peak day is about 26.5 million payments: Shopify's 2024 Black Friday weekend was $11.5 billion over 4 days, about $2.9 billion a day.
Reading 100,000 rows a second from each list, the merge takes 26,500,000 / 100,000 = 265 seconds, about 4.5 minutes. It runs on a reader.
Step through the figure to see one payment balance, and the report match it.
The trade-off:
The ledger is more rows to write, and reconciliation is a daily job someone must watch. Without them, a missing dollar is found by an auditor, not by us.
Double entry, and checking it against the PSP
- Four entries, and the two sides balance at 10,856 cents. Our margin is 399 - 345 = 54 cents.
- We match the net amount by PSP reference across a 3-day window. Most mismatches are only late; what is left is flagged.
Check in: Write the four entries for a $108.56 payment with a $3.45 PSP fee and a $3.99 platform fee. Then say which amount reconciliation matches.
Show an answer
Debit PSP receivable 10,511 and PSP fee expense 345. Credit merchant payable 10,457 and platform fee revenue 399. Each side is 10,856 cents.
Reconciliation matches the PSP's net amount, 10,511, against PSP receivable, per payment, across a rolling window of about 3 days. The payout total is matched against cash.
Black Friday, and the writer
The interviewer asks: It is Black Friday. What breaks first?
The peak is 700 payments and 700 reads a second: an entry of 1,400, or 3.5 times the ordinary peak.
Each payment is 3 writes: 700 x 3 = 2,100 writes a second, against the writer's 2,000.
Why not add readers? A reader takes reads only. Five more readers leave the writer at 2,100 against 2,000.
The reader is at 700 / 2,000 = 35%. Reads are not the problem.
Press 400 on Stage 2. At the ordinary peak, nothing saturates.
Press 1,400. The Payments database saturates.
Read the stage's warnings: "Writer 105% used: 2,100 writes/s against 2,000 on the writer alone (readers take no writes)."
The latency tile still shows 1.00s, with no "≥". The tile shows the slowest path: the PSP call, which the buyer waits for.
The writes are drawn beside the PSP call, not before and after it. So the red writer card is the wall, and the tile does not move.
In a real checkout, the writes queue in front of the PSP call and after it, and checkouts time out.
The cost tile shows $1,952/mo.
The trade-off:
Black Friday is one day, but the writer must carry its peak. A design sized for an ordinary day fails on the day that matters most.
Stage 2: The same design on Black Friday, 1,400 a second
- Load: 1,400 req/sec
- First to saturate: Payments database
- Estimated cost: $1,952/mo
- Payments database: Writer 105% used: 2,100 writes/s against 2,000 on the writer alone (readers take no writes).
Check in: The reader is at 35%. Would a second reader help? Say why.
Show an answer
No. Every write goes to the one writer; a reader adds read capacity only. Reads were at 35%, and the writer is at 2,100 against 2,000.
Scale the writer up
Writes scale up, not out, until we shard. So we give the one writer more processors.
We move the cluster to db.r6g.2xlarge: 8 x 1,000 / 2 = 4,000 writes a second. 2,100 / 4,000 = 52.5%.
The cluster uses one instance class here, so the reader doubles too.
Scale up has a long runway. A db.r6g.16xlarge has 64 vCPUs: 32,000 writes a second, 15 times today's peak.
We size up before November and down after January. A change of instance class is a short failover to a reader of the new size.
Next year's Black Friday, 870 payments a second, fits: 870 x 3 = 2,610 writes, 65%.
Press 1,400 on Stage 3. Nothing saturates. The note on "First to saturate" names the Payment API, at 56%.
The cost tile shows $2,925/mo. Kept all year, an ordinary average month is about $2,260: Stage 1's $1,287, plus $973 for the bigger pair.
With this writer, the entry that breaks it is 4,000 / 1.5 = about 2,667 requests a second.
Press 2,800. The Payments database saturates: 1,400 payments x 3 = 4,200 writes, against 4,000.
The Backup PSP card is red too, at 280 of its 300. That is under its limit, so the result does not name it.
The trade-off:
The bigger writer doubles the database bill, to $1,947 a month for the pair. Sizing down after January saves about $973 a month for nine months.
Stage 3: A bigger writer for the peak
- Load: 1,400 req/sec
- First to saturate: Nothing at this load
- Estimated cost: $2,925/mo
Check in: Which number on the database card did you change, and why not the other one?
Show an answer
The instance class, db.r6g.xlarge to db.r6g.2xlarge, which doubles the writer to 4,000 writes a second.
The reader count adds read capacity only, and reads were at 35%.
A hot merchant without a hot row
The interviewer asks: A creator's product drop sends one merchant a quarter of the peak. What breaks?
One merchant gets 25% of the peak: 0.25 x 700 = 175 payments a second. The writer has room, so the question is one row, not the machine.
We work it by hand.
Say each payment updates the merchant's balance row inside the record transaction. The row stays locked from its UPDATE to the commit.
We assume the commit waits about 2 ms, for 4 of 6 storage copies, and the other statements take about 1 ms.
UPDATE first, then the ledger inserts, then commit: about 3 ms. One row takes about 333 a second, and 175 use 53% of it.
UPDATE last, just before commit: about 2 ms, about 500 a second, 35% used.
Lock, call the PSP, update, commit: about 1,003 ms. The row then takes about 1 a second, and 175 arrive.
Our choice puts no shared row on the payment path. Ledger rows are insert only, and a job folds them into the balance every few seconds.
If a balance must be live, use 8 sub-balance rows per merchant, picked by attempt ID. They take 8 x 333 = about 2,667 payments a second for that merchant.
The trade-off:
A folded balance is a few seconds behind the ledger. That is the price of having no hot row on the payment path.
Check in: One merchant gets 175 payments a second. Where do you write its balance, and why?
Show an answer
Not on the payment path. Ledger rows are insert only, and a job folds them into the balance every few seconds, so no shared row is locked.
A balance row in the transaction holds about 3 ms a payment: about 333 a second, 53% used by 175. Inside the PSP call, it is about 1 a second.
The primary PSP goes down
The interviewer asks: The primary PSP goes down at 12:01 on Black Friday. What happens?
The primary answers errors and timeouts. A circuit breaker on it trips and sends every new payment to the backup.
The backup's contract is still 300 a second. It now gets all 700.
The breaker watches three signals over a 5-second window. Errors over 20%, p95 latency over 3 seconds, or more than 1,200 calls open trips it.
The 1,200 is 80% of the 1,500 contracted open calls, and p95 is the time 95% of calls beat.
Its states: closed, calls flow; open, calls go to the backup at once; half-open, after 30 seconds, 1% of calls probe the primary.
Thirty seconds of healthy answers close it again.
Why latency and open calls? A primary slowed to 5 seconds a call gives no errors under an 8-second timeout, and open calls climb to 560 x 5 = 2,800.
At 1,500 open, it completes only 1,500 / 5 = 300 a second. Open calls pass 1,200 within 1 to 2 seconds, so the breaker trips in its window.
While it decides, the primary's share keeps arriving: 560 x 5 = about 2,800 attempts fail or go unknown.
Our target is 99.95% of attempts on the peak day, in one Region. That allows 26.5 million x 0.05% = 13,250 failures, so 2,800 fit.
The backup passes on 300 a second, so 400 of every 700 payments a second fail. A backup sized for its everyday share is not a backup.
A 30-minute outage at the peak fails 400 x 1,800 = 720,000 checkouts, about $78 million of orders.
A timeout at the primary is "unknown". Retry it at the backup, and the card can be charged twice, so we ask the primary by its attempt ID.
A failure we know never reached the primary, such as a refused connection, is safe to retry at the backup at once.
The backup can charge the card at all because cards are held in a third-party vault, not in one PSP.
Step through the figure to see the breaker trip and the backup fill.
Press 1,400 on Stage 4. The Backup PSP saturates. The Primary PSP card shows no numbers: it is down, so the router sends it nothing.
Read the stage's warnings. The Backup PSP line says it is over its configured capacity of 300 a second.
The latency tile shows "≥ 4.00s", a lower limit: SysPad takes the backup's 1,000 ms and multiplies it by 4 at full load.
The writer gets 1,400 + 300 = 1,700 writes. The cost tile shows $2,801/mo, less than Stage 3 only because fewer webhooks arrive.
Press 400. On an ordinary day, the backup alone carries everything: 200 of its 300.
The trade-off:
A backup costs nothing until the day it is needed. Its contract must cover that day, not an ordinary one.
The primary PSP goes down
- On Black Friday the primary takes 560 a second and the backup 140. Both have room.
- A slow primary gives no errors, but open calls pass 1,200 within 2 seconds. The circuit breaker opens, and new payments go to the backup.
- The primary goes down, and the backup gets all 700. Its contract takes 300, so 400 payments a second fail.
- A payment in flight when the primary died may be charged. We mark it unknown and never retry it at the backup.
- Contract the backup for the whole peak, 1,200 a second. Now 700 fits, at 58%.
Stage 4: The primary PSP is down, and the backup takes everything
- Load: 1,400 req/sec
- First to saturate: Backup PSP
- Estimated cost: $2,801/mo
- Latency past saturation is a lower bound, not an estimate. Backup PSP is saturated on the critical path, where the queueing model stops tracking a real queue - the true figure is higher by an amount this model cannot know. Relieve the bottleneck to get a latency number worth reading.
- Backup PSP: External dependency above its configured capacity (300 rps).
Check in: The backup was at 47% before the outage. Why is it red now?
Show an answer
It was sized for its 20% share: 140 of 300. Failover sends it 100%: 700 against 300.
Contract the backup for the whole peak
The backup must carry everything on the day the primary is down. Its limit is a contract, and Stripe asks for 6 weeks' notice for a large increase.
We raise the backup's contract to 1,200 a second. Next year's peak is 870, and the year after that is 870 x 1.24 = 1,079.
The $4.6 million a minute is also an average over a minute, so single seconds run higher. 1,200, with 1,200 calls open, covers both.
700 / 1,200 = 58.3%. Every webhook arrives again, so the writer is back at 2,100 writes, 52.5%.
We still send the backup 20% every day, and we test the failover monthly by moving real traffic.
With this mix, the backup runs out at an entry of 1,200 / 0.5 = 2,400, and the writer at 2,667. The walls are close, so we size them together.
Press 1,400 on Stage 5. Nothing saturates, and the Primary PSP card stays empty: it is still down.
The note on "First to saturate" names the Backup PSP, at 58%. The latency tile shows 1.00s again: the PSP call, now through the backup.
The cost tile shows $2,925/mo, the same as Stage 3. The fix is a contract, not a machine.
Press 2,500. The Backup PSP saturates: 1,250 payments a second, against 1,200.
The Payments database card turns red too, at about 93%. That is under its limit, so the result does not name it.
The trade-off:
The contract adds nothing to this bill. It is the cheapest insurance on the page, and it must be signed before the season.
Stage 5: The backup contracted for the whole peak
- Load: 1,400 req/sec
- First to saturate: Nothing at this load
- Estimated cost: $2,925/mo
Check in: What did you change, and what did it cost?
Show an answer
The backup's contracted limit, 300 to 1,200 a second. No machine changed, so the bill is the same as Stage 3.
The API and the data model
The interviewer asks: Walk me through the API and the data model.
None of these parts breaks under load, so no stage is about them. An interviewer asks about each one, so we answer each in a line or two.
Pay: POST /v1/checkouts/{checkout_id}/attempts with the amount, currency and vault token. A new UUID per attempt goes in the Idempotency-Key header.
It returns 200 with the attempt ID and the outcome: succeeded, requires_action or failed. Until the outcome is known, it returns 202, processing.
Errors: 402 for a decline. 409 for the same key with a different body, or for a checkout that already has a live or paid attempt.
Status: GET /v1/attempts/{id} returns the status, amount and PSP reference, or 404.
Refund: POST /v1/payments/{id}/refunds, with its own key. It returns 409 for more than was captured.
Balance: GET /v1/merchants/{id}/balance returns available and pending, per currency. Webhooks: POST /webhooks/{psp} answers 200 within milliseconds.
Data: payment_attempts is keyed by the attempt ID, which is the idempotency key. It holds the checkout, merchant, amount, currency, status and PSP reference.
A partial unique index on the checkout, over processing, unknown and succeeded attempts, allows one live or paid attempt per checkout.
ledger_entries is insert only, unique on attempt and entry type. processed_events is keyed by PSP and event ID, kept 30 days.
merchant_balances holds available and pending per currency, folded from the ledger every few seconds.
Cards: two PSPs can charge one card only if it is held somewhere neutral. This design assumes a third-party vault, with hosted fields and a forwarding proxy.
"We see only a token" means the vault's token, which works with either PSP. Our servers stay out of the cardholder-data environment.
The vault's proxy sits on the payment path. At an assumed 20 to 50 ms it adds 2% to 5% to the PSP call, and it is not drawn.
Our own vault would put us in full PCI scope, the card security rules, with a yearly audit.
Network tokens from the card networks are portable only if we are the token requestor. Through one PSP, they tie us to it.
Currency: we store the rate taken at authorisation with the payment. Rates refresh every minute from a cache.
Fraud: the PSP's risk score runs inside the call. An in-house model would add about 15 ms, 1.5% of the PSP's second.
The trade-off:
Each choice has a price. The vault adds its proxy to every payment, and an in-house fraud model would add its time too.
Check in: A shopper presses Pay and gets 202, processing. Say what the page does next, and what a second press with the same key gets.
Show an answer
The page polls GET /v1/attempts/{id}. A second press with the same key also gets 202, processing, and never starts a second PSP call.
Once the outcome is recorded, a repeat gets the stored 200 with succeeded or failed.
Say this in 45 minutes
The interviewer asks: What fails in this design, and what does a shopper or a merchant see?
A design that works on a good day is half an answer. The interviewer asks what fails, who notices, and how a lost dollar is found.
Auth and capture: we authorise at checkout, and capture shipped goods at shipment. An online card authorisation usually holds about 7 days.
A full refund, by our policy: debit merchant payable 10,457 and platform fee 399, and credit PSP receivable 10,856. The PSP keeps its 345.
Read-your-writes: a status read just after paying could reach the reader and see "processing". So for 5 seconds, that shopper reads the writer.
Webhooks: answer 200 at once. After a 1-hour receiver outage at the peak, up to 700 x 3,600 = 2.52 million events come back.
Each event is a write, so the writer's spare caps the drain: 2,520,000 / (4,000 - 2,100) = about 22 minutes at the peak.
At the ordinary peak it is about 12 minutes. So we cap the worker at the writer's spare, or it floods the writer and fails live payments.
Payouts: 1 million merchants at the PSP's 15 creates a second take 1,000,000 / 15 = 18.5 hours. So payouts go as bank batch files.
The dead-letter queue: alarm on any message, fix the cause, then send the messages back. Dedupe and forward-only updates make that safe.
A Region outage of 10 minutes at the peak hits 700 x 600 = 420,000 attempts. Aurora Global Database loses about 1 second of data and recovers in about 10 minutes.
Sharding later, by merchant: a payment's three writes stay on one shard. But a merchant with 25% of the peak puts 525 writes a second on its shard.
Say it out loud: "The system costs about $1,300 in an ordinary month. The money is in the PSP fees: $3.45 a payment at list price."
At list price, 2.69 billion payments pay about $9.3 billion a year in PSP fees. A platform this size negotiates lower: at an illustrative 2%, $5.8 billion.
Minutes 0 to 5: requirements. Pay, record, status, webhooks, payouts and reconciliation. Say "never charge twice" first.
Minutes 5 to 10: the numbers. $292.3 billion / $108.56 = 2.69 billion payments a year, 200 a second at an ordinary peak, 700 on Black Friday.
Minutes 10 to 15: the API with an idempotency key per attempt. Attempts, ledger entries with their PSP fee, and processed events.
Minutes 15 to 22: create, call the PSP, record in double entry, and webhooks through a queue.
Minutes 22 to 28: exactly-once effects. The retry order, the webhook race, and the key that stops each repeat.
Minutes 28 to 34: Black Friday. The writer is the wall, readers do not help, scale up, and the hot merchant by hand.
Minutes 34 to 40: the primary PSP fails. The circuit breaker, a backup sized for the whole peak, and unknown is not failed.
Minutes 40 to 45: reconciliation and its 3-day window, payouts, the vault, a Region outage, and sharding later.
The trade-off:
Each answer has a price. The backup contract, the vault and the monthly failover test cost effort every month, for a day that may never come.
Check in: Set a timer for 45 minutes. Give the whole answer out loud, and check it against the pacing plan.
Say it out loud
- How do you get to 400 requests per second?
- $292.3B / $108.56 = 2.69B payments a year, 85.4 a second. x 2.2 = 188, rounded up to 200. Plus 1 read each: 400.
- How big is Black Friday?
- $4.6M a minute / $108.56 = 706 payments a second, rounded to 700. With 700 reads, 1,400: 3.5 times the ordinary peak.
- Is the idempotency key per checkout or per attempt?
- Per attempt, so a declined card can be followed by a new one. A partial unique index allows one live or paid attempt per checkout.
- Pay is pressed twice and our PSP call times out. In what order do things happen?
- Created as processing. The duplicate gets 202 processing. We retry the PSP with the same ID at 1, 2 and 4 s. After 8 s, unknown.
- Why is the writer the wall?
- Each attempt is 3 writes, all on the writer. At 2 ms a write, a 4-vCPU writer takes 2,000 a second: 667 payments. Readers add no write capacity.
- The call and the webhook both try to record the outcome. Who writes the ledger?
- Whichever wins a forward-only compare-and-set, in the same transaction. The other changes 0 rows. Entries are unique on attempt and entry type.
- A PSP call gets no answer for 8 seconds. Is the payment failed?
- No, it is unknown: the card may be charged. A sweeper asks the PSP by the same key. Never charge again at another PSP until you know.
- What are the entries for a $108.56 payment, a $3.45 PSP fee and a $3.99 platform fee?
- Debit PSP receivable 10,511 and PSP fee 345. Credit merchant payable 10,457 and platform fee 399. Both sides are 10,856 cents.
- How do you reconcile?
- Match the PSP's net amount per payment against PSP receivable, by PSP reference, across a rolling 3-day window. Flag what still does not match.
- How do you keep a hot merchant from becoming a hot row?
- Do not update a balance on the payment path. Ledger rows are insert only, and a job folds them into the balance every few seconds.
- When does the circuit breaker on the primary PSP trip?
- On any of three signals over 5 seconds: errors over 20%, p95 over 3 seconds, or more than 1,200 calls open. A slow PSP gives no errors.
- How big should the backup PSP contract be?
- 1,200 a second: next year's peak of 870, the year after's 1,079, and the seconds inside the busiest minute. Not its everyday 20% share.
Check yourself
The writer takes 2,000 writes a second. Each payment is 3 writes, and payments are half the entry rate.
At what entry rate does the writer saturate?
- About 1,333 requests a second
- 2,000 requests a second
- About 667 requests a second
On Black Friday the writer is at 105% and the reader at 35%.
Does a second reader help?
- No. Readers take reads only
- Yes. More instances mean more capacity
The shopper presses Pay, and the attempt is still "processing" at the PSP. The shopper presses Pay again with the same key.
What does the second press get?
- 202, processing, with Retry-After 1
- The stored 200 result
- A second PSP call with the same key
Our call to the PSP times out after the card may have been charged.
What do we do?
- Retry with the same attempt ID inside 8 seconds, then mark it unknown
- Retry with a new key
- Retry at the backup PSP
The PSP call returns "succeeded" just as the "succeeded" webhook is processed. Both try to record it.
How are the ledger entries written only once?
- A forward-only compare-and-set; the path that changes 1 row writes the entries
- Both write, and the later entries replace the earlier ones
A $108.56 payment. The PSP keeps a $3.45 fee, and our platform fee is $3.99. We debit PSP receivable 10,511 cents.
What do we credit to merchant payable?
- 10,457 cents
- 10,511 cents
- 10,856 cents
One merchant gets 175 payments a second. Each payment updates the merchant's balance row and holds its lock about 3 ms.
How busy is that row?
- About 53% of the time
- 175 times over its limit
The primary PSP slows from 1 second to 5 seconds a call. Calls still finish under the 8-second timeout.
Which breaker signal trips?
- Open calls, which pass 1,200 within 1 to 2 seconds
- Errors over 20%
The primary PSP is down at the Black Friday peak of 700 payments a second. The backup is contracted for 300.
How many payments a second fail?
- 400
- None, because a backup exists
Next year's Black Friday peak is about 870 payments a second, and sales grow about 24% a year.
What backup contract do you sign before the season?
- About 1,200 a second
- Exactly 870 a second
- 300 a second, its current share
1 million merchants are paid on one day. The PSP's payouts endpoint takes 15 creates a second.
How long do one-by-one API payouts take?
- About 18.5 hours
- About 18 minutes
Your turn
On Stage 1, press 1,500 and choose "Open in SysPad", then press Simulate. The result names the Payments database.
Two settings on that card look like capacity: "Read replicas" and "Instance class". Only one of them adds write capacity.
Change that one until the result names no bottleneck. Then say which part is now the busiest.
Open the last stage in the editor and change it. Or start from an empty canvas and build the design from memory.
Lessons for this problem
- Modelling users and traffic entry points
- How Application Load Balancer works
- How ECS Fargate works
- How Stripe works
- How Amazon Aurora works
- How SQS works
The finished architecture
A complete design of this problem in the example gallery. It may size some parts differently.
Previous: Design a hotel reservation system · Next: Design a digital wallet