Skip to lesson
‹

Courses / System Design Interview / Chapter 37

Module 3 · Design Real Products · Chapter 37 of 39

Design a Payment System

20 sections · ~42 min✓ Fact-checked

Builds on Abstractions That Hide the Network, Availability, Reliability, Scalability and Latency, Databases: Replication, Partitioning and Choosing a Store, Message Queues, Publish-Subscribe, Distributed Task Schedulers, Sharded Counters, Design a Ticket Booking System and Consensus, Leader Election and Coordination. Next up: Lessons from Real Outages.

Part 1 · Requirements and Scale

The brief

8 pm on the first night of a festive sale. Meena orders a ₹1,000 pressure cooker and approves the UPI request with her PIN. The app spins for 20 seconds, then says "Payment failed". She pays again, and the order goes through. Next morning her bank shows two debits of ₹1,000. The first payment had worked: the marketplace's server stopped waiting for the answer and guessed. Multiply Meena by thousands of buyers on a busy night and you get angry customers, a pile of refunds and books that no longer add up. A payment system that never guesses is what this chapter builds.

You are building the payment system for a large online marketplace in India. Buyers pay by card, UPI or the wallet balance they keep with you. The marketplace keeps a fee, pays sellers every day, and refunds buyers when needed. It must never charge twice, never lose a rupee, and always be able to show an auditor where every rupee went. One order runs through every page: Meena's ₹1,000 order ord_512, paid by UPI, from seller S-17.

What the service must do

Meena's double debit happened in the part of the marketplace that handles money, so start by saying exactly what that part must do. A marketplace sits between buyers and sellers like a shop counter between customers and suppliers: the buyer pays the marketplace, the marketplace keeps a fee, and the seller is paid later. The payment system is the part that talks to the world of banks and keeps the books.

Here are its requirements. The non-functional rows are the ones that shape the design.

Requirement
FunctionalPay for an order by card, UPI or wallet, and see the result: paid, failed or pending
FunctionalOn cards, hold the amount at checkout and take it only when the order is confirmed
FunctionalRefund all or part of a payment, to the original method or to the wallet
FunctionalPay each seller daily: their sales minus fees and refunds
FunctionalShow buyers, sellers and support the full history of any payment
Non-functionalNever charge twice for one order; never lose or invent a rupee
Non-functionalEvery change to money is an append-only record: who, what, when and why
Non-functionalCheckout survives a server, zone or region failure; when unsure, it says pending
Non-functionalCard numbers never touch our servers, and payment data stays in India

Speed is missing from that list on purpose. The bank's side of a payment takes seconds, because the buyer types an OTP or a PIN, so nobody notices our 100 ms. Correctness beats latency in every trade-off in this chapter: when the system is unsure, it waits and checks rather than guessing.

Out of scope today

Fraud scoring, pay-later and EMIs, currency conversion, tax invoices, seller onboarding and disputes (chargebacks). Fraud scoring would be one more call in the pay path, and a dispute is more ledger entries, so neither changes the core design.

How money actually moves

Before designing anything, follow Meena's ₹1,000 out of her account. Our service never moves money itself. It asks a payment gateway to collect it: a company that connects merchants to card networks, banks and UPI, and reports each result back. Think of it as a courier that carries our request to her bank and brings the answer home.

For UPI, the gateway's request reaches Meena's UPI app, she approves it with her PIN, and the result flows back through the banks. Until then the payment is pending: she may still be finding her phone, or a bank may be slow. The real money reaches our bank later, usually in one lump sum a day, with a settlement file listing every payment and fee inside it.

Cards and our own wallet behave differently, in ways that matter to the design:

CardUPIWallet
Payer approves withA check by their bank, usually an OTPTheir UPI PIN, in their UPI appUsually nothing extra: it is our own balance
Hold now, take later?Yes: authorise at checkout, capture within daysUsually no: the money moves at onceYes: move it to a held account in our ledger
Can the outcome be unknown?Yes, after a timeoutYes: it can stay pending for minutesNo: one transaction in our database
A refund goesBack to the card; can take days to showBack to the payer's bank accountBack to the wallet at once
Payer approves with

CardA check by their bank, usually an OTP

UPITheir UPI PIN, in their UPI app

WalletUsually nothing extra: it is our own balance

Hold now, take later?

CardYes: authorise at checkout, capture within days

UPIUsually no: the money moves at once

WalletYes: move it to a held account in our ledger

Can the outcome be unknown?

CardYes, after a timeout

UPIYes: it can stay pending for minutes

WalletNo: one transaction in our database

A refund goes

CardBack to the card; can take days to show

UPIBack to the payer's bank account

WalletBack to the wallet at once

Only the wallet, which lives in our own ledger, is never unknown; every outside method can leave us waiting for an answer.

Card numbers stay with the gateway. Its card form, a hosted page or part of its app SDK, sends the number straight from the buyer's device to the gateway, which returns a token: a stand-in id, useless anywhere else, that we use to charge a saved card. That keeps most of our system out of scope for the card industry's security standard, PCI DSS. Indian rules also limit who may store card numbers, so check them before designing saved cards.

How much money moves

How busy is this system, and what does a mistake cost? Assume about 20 lakh paid orders a day at an average of ₹1,000, like Meena's, with big sales a few times a year.

Try it first: at a sale opening, payments run about 40 times the ordinary daily average. How many payments a second is that, and how much money does a 0.01% error rate get wrong each day?
  • Ordinary rate: 20 lakh ÷ 86,400 s ≈ 23 a second; × 40 ≈ 1,000 a second.
  • Money per day: 20 lakh × ₹1,000 = ₹200 crore.
  • Errors: 0.01% of ₹200 crore = ₹2 lakh a day charged twice or lost.

Here is the full set of numbers:

QuantityWorkingResult
Paid orders, ordinary day20 lakh ÷ 86,400 s≈ 23 /s
Payment attemptsabout 1.3 per order: declines, abandoned UPI requests≈ 30 /s
Payments at a sale openingabout 40× the ordinary rate for the first minutes≈ 1,000 /s
Rows written per paymentpayment, its history, key, outbox, 3–5 ledger entries≈ 10, so 10,000 rows/s at peak
Money moved per day20 lakh × ₹1,000≈ ₹200 crore
Storage per year20 lakh × about 2 KB × 365≈ 1.5 TB; 4.4 TB with 3 copies

Payments are a correctness problem, not a scale problem. A thousand payments a second fits on a handful of database machines, but every wrong rupee is an angry customer or an audit finding. The numbers settle four design questions:

  • Transactions wherever money changes. At this rate, relational transactions are affordable, and they make "both or neither" easy.
  • The bank sets the pace. Our work takes milliseconds; an OTP or a UPI PIN takes the buyer seconds or minutes. So paying is asynchronous: start, let the buyer approve, then learn the result.
  • Hot accounts, not hot tables. Every payment credits our fee account, and a big seller may get 200 payments a second in a sale. Those single rows need care (the hot accounts page).
  • Small data, kept for years. Older years move to cheaper storage; check the retention rules for your business.

Part 2 · API, Data and the Big Picture

The API

With the scale known, define how Meena's payment starts. The order service creates payments, not the buyer's phone: it knows the true amount, and a phone can be tampered with. The phone only shows the gateway's card page or opens the UPI app. Every write carries an idempotency key: a unique name for one intended action, like a receipt number written down before money changes hands, so a repeat can be recognised.

http
POST /v1/payments                     (called by the order service)
     Idempotency-Key: ord_512:pay:1
     { orderId: "ord_512", amount: 100000, method: "upi" }
                      -> 201 { paymentId: "pay_81", status: "pending",
                               next: "approve in your UPI app" }
GET  /v1/payments/pay_81              -> 200 { status: "captured", ... }
POST /v1/payments/pay_81/capture      Idempotency-Key: pay_81:capture
                                      -> 200 { status: "captured" }
POST /v1/payments/pay_81/void         -> 200 { status: "voided" }
POST /v1/payments/pay_81/refunds      { amount: 40000 }
     Idempotency-Key: ord_512:refund:1 -> 201 { refundId: "rf_9", status: "pending" }
POST /v1/gateway-events               (sent by the gateway, signed)
     X-Signature: sha256=9c1f...      -> 200 once safely stored
GET  /v1/sellers/s_17/balance         -> 200 { payable: 9800000 }

Amounts are integers in paise: ₹1,000 is 100000. Floating-point numbers cannot hold most decimal fractions exactly, so money is never a float.

Four rules make the keys work:

  • One idempotency key per intended action, made and stored before the first try, so even a caller that crashed retries with the same key. A new attempt after a decline gets a new key (ord_512:pay:2).
  • The server stores each key with a hash of the request and the response. A repeat gets the saved response; the same key with a different amount is rejected; a repeat that arrives while the first is still running is told to retry shortly (409).
  • A second guard in the data: (order_id, attempt) is unique, so even after a key expires, one attempt can never become two payments.
  • pending is a real answer. The API never turns "not known yet" into "failed".

The data model

Where is Meena's payment recorded? Two kinds of data sit here, in separate databases. Payment records track each attempt to collect money and change state as results arrive, like a parcel tracker. Ledger entries record money itself, like lines in a bound account book: written once and never changed.

paymentspayment_id (pk)order_id, attempt (unique together)amount (paise), methodstatus, versiongateway, gateway_ref (unique)refunded (paise)
payment_eventspayment_id, seq (pk)from_status, to_statussource: api, webhook, poll or reconat
idempotency_keyskey (pk)request_hashstate: running or doneresponse, expires_at
ledger_txnstxn_id (pk)kind: capture, refund, payout, reversalreference (unique)created_at, created_by
entriesentry_id (pk)txn_idaccount_iddebit or credit (paise)created_at
accountsaccount_id (pk)kind: wallet, seller or houseowner_idbalance (cached), version

Each kind of data sits where its guarantees are cheapest:

DataWhere it livesWhy there
Payments, events, keys, outboxA relational database with a synchronous standby in another zoneConditional updates on status; a confirmed payment must survive a crash
Ledger postings and entriesIts own relational database, append-onlyEach posting is one transaction whose debits equal its credits
Settlement filesA blob store, kept for yearsThe gateway's own record, needed for audits and disputes
Card detailsOnly at the gateway; we keep its token and the last four digitsOur systems stay out of most card-security audits

A payment row changes; a ledger entry never does. A mistake is fixed with a new posting that reverses the old one, so the books show both the error and the fix, which is what auditors want. Refunds and payouts follow the same pattern in tables of their own. At our peak one primary per database copes, and payments can later be split by payment_id.

The payment state machine

Meena's payment row needs one field everyone trusts: its status, the single source of truth about her money. Like a parcel tracker, it only moves forward along allowed arrows. The difficulty is that five sources can move it: the API, the gateway's direct reply, its webhook, the status checker and reconciliation, and their messages may arrive late, twice or out of order.

These are the states and the arrows between them:

StatusMeaningCan move to
createdSaved; not yet sent to a gatewaypending, failed
pendingSent, or about to be; the outcome is unknownauthorised, captured, failed
authorisedA card hold is in place; nothing taken yetcaptured, voided
capturedMoney taken; refunds are separate recordsnothing: final
failed, voidedNo money moved, or the hold was releasedfinal, except a late success (below)

Every arrow is one conditional update: change the row only if it is still in a state the arrow starts from, then check how many rows changed. It is the ticket booking chapter's seat trick, applied to money.

sql
BEGIN;
UPDATE payments
   SET status = 'captured', version = version + 1
 WHERE payment_id = 'pay_81'
   AND status IN ('pending', 'authorised');
-- 1 row:  insert a payment_events row and an outbox row, then COMMIT
-- 0 rows: ROLLBACK and read the row
--         already captured: a repeat, so do nothing
--         failed or voided: a conflict, handled below

Every source calls this one function, so the order in which results arrive does not matter.

Three consequences follow:

  • A late failure never beats a success. failed can only follow created or pending, so a failure message after a capture changes nothing except raising an alert.
  • A late success after a failure is real money. If the gateway reported a UPI payment as failed or expired and a success arrives later (rare, but it happens), a separate, audited path records the capture, then attaches it to the order if still open, or refunds it automatically.
  • History and events cannot drift: the status change, its payment_events row and its outbox row commit together.

The high-level design

With the states defined, here is the machinery that moves them. The design has a fast front half that starts payments and hears results, and a careful back half that records money. An outbox row, written in the same transaction as each status change and later published to a queue, joins the two, so a slow ledger never blocks a checkout and a crash never loses a result.

Animated sceneFrom checkout to the ledger0:26

Watch for what moves pay_81: only the gateway's signed webhook, never the buyer's app

Read it as steps

Meena's ₹1,000 UPI payment goes from checkout to the ledger, and checkouts carry on while the ledger restarts.

Meena's payment changed state only on the gateway's signed message, and the ledger heard about it through an outbox row committed in the same transaction. When the ledger stopped, events simply waited: nothing in the front half waits for the back half.

Each box has one job:

  • Payment service: stateless servers owning the state machine, with one adapter per gateway, so adding a gateway is not a rewrite.
  • Webhook receiver and status checker: two more doors into the same conditional update; the checker runs a delayed task per pending payment (the task scheduler chapter).
  • Outbox relay and event queue: at-least-once delivery (the message queue chapter), so every consumer dedupes.
  • Ledger: its own service; each posting has a unique reference such as pay_81:capture, so an event delivered twice posts once.
  • Reconciliation and payouts: nightly batch jobs, covered later.

Checkout across three services

Meena's checkout touches more than payments. Three services each own their data: orders, stock and payments, and no single database transaction covers them. A two-phase commit, where every participant locks its rows until all agree, would hold the cooker's stock row while Meena types her PIN, and the bank cannot join one anyway.

Think of booking a holiday yourself: you book the train, then the hotel, and if the hotel is full you cancel the train. Checkout works the same way, as a saga: a series of local transactions, each with a compensation that undoes its effect if a later step fails. An orchestrator in the order service stores each order's current step and calls the next one (the publish-subscribe chapter compares this orchestration with choreography), so support can see where any order is stuck.

Checkout as a saga
  1. 1Create the orderstatus: awaiting payment. Undo: cancel the order
  2. 2Reserve the stockheld for 15 minutes. Undo: release it
  3. 3Authorise the paymentcard: a hold; UPI: collected at once. Undo: void the hold, or refund
  4. 4Confirm the orderthe point of no return: the seller is told to pack
  5. 5Capture the payment (cards)no undo: retried with the same key until it succeeds

What the saga does when something goes wrong depends on the step:

If this happensThe saga does
Stock runs outNever asks for payment; tells the buyer at once
Payment declined or expiredReleases the stock; the buyer may try another method on the same order
Payment still pendingKeeps the stock held a little longer and waits for the status check
Payment succeeds after the stock was releasedReserves again if it can; otherwise refunds automatically
The orchestrator crashesResumes from the stored step and retries it with the same key, such as ord_512:reserve

In every row the order either moves forward or is undone cleanly; nothing is left half done.

Compensation is not rollback

A refund is a new event the buyer can see, with its own delay and sometimes a fee. That is why cards are authorised first and captured once the stock is safe: voiding a hold moves no money.

Part 3 · Deep Dives

When the bank does not answer

Core deep dive

This is the heart of the design: a money call that times out is where naive systems charge twice, as Meena's did.

Back to Meena's payment. The payment service asks the gateway to collect ₹1,000, and 10 seconds pass with no answer. As the abstractions chapter showed, the request may never have arrived, the reply may have been lost after the money moved, or a bank may just be slow. The service cannot tell which, so it must assume neither success nor failure.

Predict before you watch: the call has timed out and Meena is waiting. Should the service retry, mark the payment failed, or wait?
  • Never mark it failed: the money may have moved.
  • Keep it pending, retry only with the same key, and ask the gateway.
Animated sceneA timeout, a retry and a status check0:22

Watch for the timeout: pay_81 stays pending even after the service stops waiting

Read it as steps

Meena's UPI payment times out, a retry carries the same key, the webhook is lost, and a status check settles it.

Three habits made that safe: pay_81 was saved as pending before the call, its key went to the gateway so the retry could not debit twice, and every result used the same conditional update. A timeout means unknown, never failed, so Meena's screen should say "Confirming your payment. Please don't pay again."

The same key rule holds at every hop that can repeat a request:

HopKeyWhat a repeat does
Order service → payment serviceord_512:pay:1Gets the saved response
Payment service → gatewaypay_81Gets the first result; no new debit
Gateway → our webhookthe gateway's event idThe conditional update changes nothing
Event → ledgerpay_81:captureThe unique reference blocks a second posting

Each hop dedupes on its own key, so a repeat anywhere replays a result instead of moving money. A gateway without keys gets no retried charges, only status checks.

A ledger that always balances

A payment's status says whether money moved; the next question is where every rupee went. A balance column that payments add to and subtract from cannot explain itself: if it is wrong, nobody can say why. Picture an account book in which every rupee is written twice, once where it came from and once where it went, so the pages must always add up. A double-entry ledger works that way: every movement of money is a posting of at least two entries whose debits equal their credits, so a bug cannot create or lose money unseen.

Debit and credit are the ledger's two columns. A debit increases accounts for what we own or spend: our bank balance, money the gateway owes us, fees we pay. A credit increases accounts for what we owe or earn: money owed to sellers, our fee income. Here is Meena's ₹1,000 with our ₹20 fee, followed to seller S-17. Suppose the gateway's fee on this payment is ₹5; real fees depend on the method and the contract.

PostingDebitCredit
Capture of pay_81Gateway receivable ₹1,000Seller S-17 payable ₹980; fee income ₹20
Settlement: the gateway pays us, keeping its ₹5Bank ₹995; gateway fees ₹5Gateway receivable ₹1,000
Payout to seller S-17Seller S-17 payable ₹980Bank ₹980

After all three, the gateway owes nothing, the seller is owed nothing, and the ₹15 left in the bank is our fee minus the gateway's. Each posting balanced on its own, so the whole book balances.

A balance is never stored as truth: it is the sum of an account's entries. We cache it on the account row for speed, and check every night that each cache equals its sum and that all debits equal all credits. Mistakes are fixed with new postings that reverse old ones, never by editing. One problem remains: what happens when a thousand postings a second touch the same account?

Hot accounts

At a sale opening, every one of the 1,000 payments a second credits our fee income account, and a big seller may receive about 200. Entries are inserts, which never wait for each other. A cached balance, though, is one row that every posting must lock and update, and one row manages roughly a few hundred to a few thousand updates a second. Think of a sale with a single cash counter: the queue forms at the counter, not in the aisles.

Two fixes, often combined:

  • Keep running balances only where money leaves. A wallet debit checks its balance in the same statement (... WHERE balance >= 50000), and a buyer pays a few times a day at most. Fee income needs no balance check, so it can be summed when needed.
  • Split hot accounts, as the sharded counters chapter split a like count. Give fee income 16 sub-accounts and credit a random one per posting: about 63 updates a second each. Its balance is the sum of the 16; a big seller's account splits the same way, and a payout debits each part.

If one ledger database is not enough, shard it by seller and give every shard its own sub-accounts for the house accounts, so a capture still commits on one machine.

The cost lands on reads: a balance read sums 16 rows instead of one. A seller with a few orders a day needs none of this, so split only the accounts that are actually hot, and raise the count before a big sale rather than during it.

The ledger is now right by construction. What remains is proving that it matches what the gateway and the bank say happened.

Webhooks, polling and reconciliation

A status check settled Meena's payment after its webhook was lost. That is one of three ways we learn each result, each a safety net for the one before: like a doorbell, a call to the courier, and the monthly statement.

They differ in speed and in how they fail:

ChannelSpeedCan fail byIts job
WebhookSecondsArriving late, twice, out of order, or neverThe fast path
Status checkMinutes: checks at about 1, 5 and 15, then hourlyThe gateway's API being downCatches lost webhooks
Settlement fileA day or so laterRarely: it is the gateway's own booksThe final word, with fees
Webhook

SpeedSeconds

Can fail byArriving late, twice, out of order, or never

Its jobThe fast path

Status check

SpeedMinutes: checks at about 1, 5 and 15, then hourly

Can fail byThe gateway's API being down

Its jobCatches lost webhooks

Settlement file

SpeedA day or so later

Can fail byRarely: it is the gateway's own books

Its jobThe final word, with fees

Each later net is slower but harder to fool, so the slowest, the settlement file, has the final word.

Webhooks are untrusted until checked. The gateway signs each message with a secret only it and we know, commonly an HMAC of the body. We recompute and compare, reject old timestamps so a copy cannot be replayed, and check the amount. Only after storing the message and applying the conditional update do we reply 200, so any error makes the gateway resend.

Reconciliation is the nightly audit. Each gateway sends a file of everything it settled the day before, and our bank statement shows the lump sum that arrived. A batch job matches all three.

Animated sceneThe nightly reconciliation0:19

Watch for each line in the gateway's nightly file finding its match in the ledger

Read it as steps

Tonight's file from the gateway is matched against the ledger and the bank, and each mismatch goes where it belongs.

Most mismatches are timing: a payment captured at 11:59 pm may settle a day later. The dangerous ones are money we hold but never recorded, because a buyer paid for something we may not deliver. Every day closes with each rupee matched, or in the review queue with an owner.

Part 4 · Failures and Trade-offs

What can fail

Every fix keeps two promises: nobody pays twice, and every rupee stays accounted for. When a part fails, payments wait; they never guess.

ProblemEffectFix
Gateway slow or downPayments stay pending or cannot startA circuit breaker per gateway; new payments go to the second gateway; pending ones wait for status checks
Webhooks lost for an hourPayments look stuckThe status checker settles them; alert on pending age
Payments DB primary diesWrites fail for tens of secondsPromote the synchronous standby; callers retry with the same keys
Ledger or queue downBalances and seller views lagEvents wait in the outbox; postings catch up once each; payouts wait for the ledger
Buyer pays twice: UPI pending, then cardTwo captures for one orderThe order keeps the first; the second is refunded automatically
A zone failsOne copy of each database is goneThe standby in another zone takes over; synchronous copies lose nothing
A region failsCheckout stops thereFail over to the standby region, also in India; status checks settle payments in flight
Settlement file late or wrongThe day cannot be closedHold that gateway's payouts and chase it; never guess

Read down the Fix column and one habit repeats: when unsure, keep the payment pending and let a later safety net settle it.

Trade-offs, cost and where data lives

The design made six choices, and each has a price. Here they are side by side.

DecisionChoiceCost
RetriesThe same idempotency key at every hopKeys to store, expire and check
Unknown outcomesPending, status checks, nightly reconciliationSome buyers wait minutes for an answer
Money recordsAppend-only double-entry ledger3–5 rows per payment; fixes are new postings
Hot accountsSplit into sub-accountsA balance read sums several rows
Checkout across servicesA saga with compensationsRefunds instead of rollbacks; more states to track
Copies across regionsSynchronous, both regions in IndiaAbout 30 ms more per commit for regions as far apart as Mumbai and Delhi

The highlighted row is the heart of the chapter: a design that says "pending" can always become correct later, but one that guesses "failed" must find the buyer and give the money back.

What dominates the bill: not servers. Gateway fees, charged per payment and usually highest on cards, cost far more than the infrastructure; within our own costs, the synchronously replicated databases and years of kept records lead.

Where payment data may live. Indian rules require payment data to be stored in India. The details, such as which data counts, whom the rules bind and what may be processed abroad, have changed over time, so check the current rules with your compliance team. The design consequences are stable: both regions are Indian, so failover never leaves the country; backups, replicas, logs and analytics copies stay in India too; vendors that receive payment details must comply as well; and masking card numbers and UPI ids in logs leaves less data to protect.

Part 5 · Going further

Refunds and payouts

Optional: the flows that send money back out. Suppose Meena's cooker arrives with a cracked lid, and support refunds ₹400 of her ₹1,000.

Refunds reuse everything above: each is its own record with its own key (ord_512:refund:1) and the same states, since its gateway call can time out too, and refunds appear in the settlement file as negative lines. One conditional update keeps refunds from ever adding up to more than was paid, even when two agents click at once:

sql
UPDATE payments SET refunded = refunded + 40000
 WHERE payment_id = 'pay_81'
   AND amount - refunded >= 40000;   -- 0 rows: refund refused

Its posting runs the capture backwards: debit the seller's payable (and fee income, if the fee is returned) and credit gateway receivable, since the gateway deducts the refund from its next settlement. A refund to Meena's wallet instead is an internal posting: instant, with no gateway fee. Large refunds need a second person's approval, and both names go in the posting.

Payouts run once a day. For each seller, sum the payable entries past the return window, keep back a small reserve, and create one payout row with a unique key such as po_s17_1016, so rerunning the job cannot pay twice. The posting moves the money from the seller's payable to a payouts in transit account, and the bank's confirmation moves it on to the bank.

Payouts inherit the rules of payments:

  • Bank transfers can time out too: check the payout's status by its reference; never resend it under a new one.
  • A failed transfer is reversed, not edited: a new posting returns the money to the seller's payable.
  • Refunds after a payout can push a seller below zero; the reserve covers it, and future sales repay it.

Part 6 · Interview kit

The 45-minute answer

Here is how the 40 minutes of design in a 45-minute slot map onto this problem.

MinutesWhat you say and drawKey number or decision
0–5Requirements: card, UPI, wallet; refunds; daily payouts; never double-charge or lose a rupee; auditable; data in IndiaCorrectness beats latency
5–8Estimates: 20 lakh orders a day at ₹1,000, and a sale-opening peak≈ 1,000 payments/s; 0.01% wrong is ₹2 lakh a day
8–13API with an idempotency key on every write; payments with a status; ledger postings and entriesAmounts in paise; pending is a real answer
13–22Order service, payment service, gateway; webhooks and status checker; outbox, queue, ledger; nightly reconciliation and payoutsThe front half never waits for the back half
22–37Deep dive: the timeout and unknown outcome; then the ledger and hot accounts, or reconciliationPending, never failed; one conditional update for every source
37–40Failures (gateway down, region loss), what to monitor, trade-offsBoth regions in India; reconcile nightly

Choose the deep dive from the question. "What if the bank never answers?" or anything about double charges: the timeout and the state machine. How you know a seller's balance is right, or payouts: the ledger. How you would notice missing money: reconciliation. If asked about data location, one sentence covers it: payment data, its backups and its logs stay in India, so both regions are Indian.

Say
"A timeout leaves the payment pending, never failed. I retry only with the same idempotency key, and a status check settles it within minutes."
Say
"Money lives in an append-only double-entry ledger: 3–5 entries per payment, every posting balanced, balances derived, so a nightly check can prove them."
Say
"At about 1,000 payments a second, one primary per database is enough. The hot spots are single accounts, so I split the fee account into 16."
Avoid
"We use exactly-once delivery, so nothing is duplicated." Delivery is at-least-once; keys and conditional updates make repeats harmless.
Avoid
Confirming an order because the buyer's browser came back to our site. Only the gateway's signed message or a status check counts.

Interview drill

Interviewers often change the problem halfway. Try each one before reading the answer.

A big sale brings 10,000 payments a second for five minutes, 10 times the plan. What breaks first, and what changes?
  • The gateways, before us: agree rate limits in advance, spread payments over two or more gateways, and put a waiting room in front of checkout (the ticket booking chapter).
  • Our side scales out: stateless servers, payments split by payment_id, and a ledger that lags a few minutes, then catches up.
  • Hot accounts get 10 times hotter: raise fee income to 64 sub-accounts before the sale.
  • Rate-limit status checks per gateway, so pending payments do not become our own flood.
Buyers want to split one order between the wallet and UPI. What changes?
  • An order now has several payments, paid in full when their captures reach its total.
  • Hold the wallet part first, as an internal posting to a held account, then start the UPI payment.
  • If UPI fails or expires, release the hold; if it succeeds, capture it: a two-step saga with one compensation.
Checkout must survive losing a whole region with under a minute of downtime. What do you add?
  • A standby region, also in India, with payments and the ledger copied synchronously: about 30 ms more per commit, small beside a bank's seconds.
  • Asynchronous copies are faster but lose the last few seconds, which must be rebuilt from the gateway's status API and settlement files.
  • A fenced switch-over: the old region's writers lose their lease before the new primary takes writes (the coordination chapter), so two regions never both move money.
  • Webhook endpoints in both regions; payments in flight stay pending in the new region until status checks settle them.
Common miss
Marking a timed-out payment as failed. The money may have moved, and the buyer pays again.
Common miss
A balance column updated by every payment with no entries behind it: nobody can prove it, and big sellers make it a hot row.

Wrap-up

The whole chapter on one page, for revision.

That completes the products. Next, Lessons from Real Outages shows how real systems failed in public, and which habits from this course, such as a small blast radius and careful retries, would have contained the damage.

Part 7 · Check yourself

Quick check

Q1Support reports buyers charged twice for one order, always on days the gateway was slow. The payment service retries calls that time out. What is the likely cause, and the fix?

Think it through, then check.

Q2Sellers now want their money minutes after each sale, not once a day. What changes?

Think it through, then check.

Q3Should card payments be captured at checkout, or authorised then and captured when the order is confirmed?

Think it through, then check.

Q4The ledger database is down for 20 minutes during a sale. What happens to checkouts, and when it returns?

Think it through, then check.

Q5Pending payments are rechecked at 1, 5 and 15 minutes, then hourly. Which building block runs this, and why not a sleeping loop in the payment service?

Think it through, then check.

Next chapter · 38 of 39Lessons from Real Outages~32 min · 2 animated scenes