Someone scans a QR code at a tea stall, taps a PIN, and twelve rupees moves between two
accounts at two different banks in about two seconds. Both banks are separate companies
with separate databases and separate outage schedules. Neither of them can roll back the
other one.
UPI style payment rail / at a glance
That is the whole problem. Everything else in this design is a consequence of it.
This page uses UPI as the reference because it is the largest system of its kind and
because most people reading this have used it three times today. The design generalises:
any rail that moves money between institutions you do not control ends up in roughly the
same shape.
Payments interviews reward paranoia. Every question below is really asking "what happens
when this half succeeds", and the answers are what stop you designing a distributed
transaction that cannot exist.
You ask
They say
What it settles
Who owns the accounts, us or someone else?
Banks own them. We move instructions, not money.
The single most important answer. You cannot roll back another company's database, so every failure is undone with a compensating payment rather than an abort.
What is the peak rate, and when does it happen?
Tens of thousands a second, spiking hard on salary day and festival evenings.
Capacity planned for the spike, not the average, and a queue in front of anything that talks to a bank, because banks do not scale on your schedule.
Is it acceptable to debit twice under retry?
Absolutely not. Ever.
An idempotency key on every leg, generated once by the payer app and carried end to end. Retries are certain, so exactly once has to come from the key rather than from hope.
What if a bank does not answer at all?
Treat it as unknown, not as failed.
A third outcome besides success and failure. Timeouts get resolved by asking the bank later, and never by guessing, which is the subject of a whole deep dive.
How fast does the payer need an answer?
Under a few seconds, or they pay in cash instead.
A synchronous request path with hard deadlines, and everything that can be done afterwards being done afterwards.
Who decides what actually happened, in a dispute?
The end of day settlement file. Not our database.
Reconciliation as a first class part of the design rather than an operations afterthought. Your ledger and theirs will disagree, daily, and the process for that is the product.
Published monthly volumes for UPI have been well north of 15 billion transactions a month,
and the figure keeps climbing, so treat any specific number as a checkpoint rather than a
constant. What matters is the shape of the arithmetic.
Peak throughput
18 billion
2.5x
3x
Average per second18B / 2.6M seconds = 6,944 tps
Peak per second6,944 x 2.5 x 3 = 52,083 tps
Ledger writes at peak4 per transaction = 208,333 per second
Ledger rows per month18B x 4 = 72 billion
52,083 tps
peak, and every one of them is a two phase money movement
Four ledger writes per transaction, because each leg is a double entry pair. That turns 52,083 payments a second into 208,333 appends a second, and it is the number that decides the ledger design.
The multipliers are not decoration. Payment traffic in India is spiky in ways an average
hides completely: salary day, the first of the month, festival evenings, the last hour
before a bill deadline. Designing for the average is designing to fail on the days that
matter most.
Why: 202 and not 200. The payment is accepted, not complete. Returning 200 with a success the moment the request is parsed is how clients end up showing a tick for money that has not moved.
Why: The client polls this, and so does every other party. Because it is the only safe way to answer "did that work", it has to be cheap enough to be called constantly.
POST/v1/payments/{txnId}/reverse
{ "reason": "BENEFICIARY_CREDIT_FAILED" }
returns 202 { "state": "REVERSING" }
Why: A reversal is a first class operation with its own id and its own ledger entries, not a delete. Every reversal must name the transaction it compensates, or you cannot reconcile it later.
Four parties, and it matters that three of them are run by different organisations.
pay request1who is this?2allowed?debit3credit4every state change56stuck rowsend of dayPayer appthe PSPCentral switchrouting + stateAddress mapperUPI id to accountRisk and limitsinline, fastRemitter bankdebits the payerTransaction storethe state machineBeneficiary bankcredits the payeeLedgerdouble entry, append onlyReconciliationagainst settlement filesTimeout sweeperresolves unknowns
Figure 1. Three of these boxes belong to different companies with different outage schedules. The switch in the middle holds the state machine and none of the money, which is the fact every failure case on this page follows from.
Walking Figure 1:
The payer's app sends one request carrying an idempotency key it generated itself.
Everything downstream is keyed off that.
The switch resolves the payee's UPI id to a real account at a real bank. This lookup
is why you can pay a phone number instead of an account number.
Risk and limit checks run inline. They have a deadline measured in tens of
milliseconds, because the whole payment has about two seconds.
The debit goes to the payer's bank and the credit to the payee's bank. These are two
separate companies, two separate calls, and no shared transaction.
Every transition is written before it is attempted, so the state machine is always
ahead of reality rather than behind it. If the switch dies mid payment, the row says
what it was trying to do.
The ledger is append only and double entry, which is how a disputed payment three
months later has an answer.
A sweeper picks up anything stuck in an unknown state and resolves it by asking the
bank, never by guessing.
And every night, reconciliation compares the ledger with what the banks say happened.
The two will disagree. Having a process for that disagreement is the difference
between a payment system and a demo.
The switch is the interesting box. It routes, it holds the state machine, and it decides
what happens when something goes wrong. What it does not do is hold funds. Nobody's money
sits in the middle, which is why a failed payment cannot be undone with a rollback and has
to be undone with a second, opposite payment.
Your answer
Step 4 is two calls to two different companies. The debit succeeds and the credit fails. Money has left one account and arrived nowhere. What should the switch do, and what must it never do?
1/8 The app sends the payer address, the payee address, the amount in paise, and an idempotency key it generated. The PIN was already verified on device against the bank, so the switch never sees it.
Why not one distributed transaction
The textbook answer to two databases is two phase commit. It is the wrong answer here
and you should be able to say why in one sentence: two phase commit requires every
participant to hold locks until the coordinator decides, and no bank is going to let an
external switch hold a lock on its ledger while a second bank is slow or offline. The
blocking property that makes 2PC correct is exactly what makes it unusable across
organisations.
Because there is no rollback, the transaction's state is the only thing standing between
a customer and a lost payment. Every transition is written down before the action it
describes is attempted, never after.
declineddebit okcredit okcredit failedmoney returnedINITIATEDFAILEDno money movedDEBITEDmoney in flightSUCCESSREVERSINGREVERSED
Figure 2. There is no edge from DEBITED back to INITIATED. Once money has moved, the only ways out are forward to SUCCESS or sideways through a reversal.
DEBITED is the state that defines the system. A transaction sitting in DEBITED is money
that has left one account and not arrived anywhere, and every design decision below exists
to make sure nothing stays there.
transactionsthe switch's own store, sharded by transaction id
txn_id
char(35)
PK
Generated by the initiator and unique for all time. This is what makes retries safe.
idempotency_key
char(64)
UQ
A unique index here is the entire duplicate payment defence.
payer_vpa
varchar(255)
Resolved to a bank and account at initiation, then frozen for the life of the transaction.
payee_vpa
varchar(255)
amount_paise
bigint
Integer paise. Never a float. Floating point money is how you get a rounding difference nobody can explain.
The index on state plus state_changed_at is not an optimisation. It is the query the recovery job runs every few seconds, and it is the difference between a stuck payment being resolved automatically and a customer calling support.
The ledger is separate, append only, and double entry. Every movement writes two rows that
sum to zero, so any imbalance is a bug you can actually detect.
ledger_entriesappend only, never updated, never deleted
entry_id
bigint
PK
Time ordered, so the log reads in the order things happened.
txn_id
char(35)
FKIDX
Every entry traces back to one transaction.
account_id
bigint
IDX
direction
enum('DR','CR')
Entries for one movement always come in pairs that sum to zero.
amount_paise
bigint
posted_at
timestamp
Sample row
80114 | UPI2508251432... | 9921 (customer) | DR | 50000
A reversal never edits these rows. It appends the opposite pair. The original debit stays in the ledger forever, because what happened is a fact and the correction is a separate fact.
Here is the case that separates people who have worked on payments from people who have
read about them. The switch sends a debit and gets nothing back.
A timeout tells you the response did not arrive. It tells you nothing at all about whether
the debit happened.
SwitchRemitter bankBeneficiary bankdebit 500, txn Adebit committedno response in 30sstatus of txn Adebited at 14:32:07credit 500, txn Adeclined, account frozenreverse txn A
1/8 The switch writes DEBIT_PENDING first, then sends. Writing after would mean a crash here leaves no record that money might be moving.
Three rules come out of that sequence, and they are worth saying in exactly these words:
Never retry a money movement. Retry the question about it. A retry can double debit. A
status check cannot. Every request carries a transaction id the bank will recognise, and
asking twice is free.
Write the intent before the action. The record that says a debit is being attempted has
to be durable before the debit is sent. Otherwise a crash at the wrong moment leaves money
moving with nothing on your side that knows about it.
No transaction gets to stay in limbo. A job sweeps for anything stuck in DEBITED or
DEBIT_PENDING past a threshold, resolves it by status check, and reverses if it must. This
job is not a nice to have. It is the component that turns "we could lose a payment" into
"we take up to a few minutes to fix a payment".
Reconciliation, because the ledger will not agree#
Two organisations, two databases, millions of transactions. Their idea of what happened
and yours will differ, in small numbers, every single day. Not because anyone is wrong,
but because a message got lost at the boundary of a batch, or a status check landed
between two commits.
So both sides exchange a settlement file for the day and compare, line by line. Three
categories come out:
Matched, which is nearly everything. Successful in our records, successful in theirs,
same amount.
In ours but not theirs. We told a customer their payment succeeded and the bank never
saw it. This is the dangerous one, and it goes to a human.
In theirs but not ours. Usually a response we never received for a payment that did
complete. The state machine catches most of these through status checks, and the rest get
corrected here.
Why this is not just a payments idea
Any time you write to two systems you do not control together, you need this same trio:
an idempotency key so retries are safe, a state machine that never leaves work in an
ambiguous state, and a reconciliation pass that assumes the two sides will disagree.
Ordering systems, inventory systems and anything talking to a third party API all have a
version of this problem, and most of them handle it worse than a payment rail does.
Healthy. Both banks answering in about 300ms. The switch holds a connection for roughly as long as a payment takes and everything moves.
Read every step as text
normal · Healthy. Both banks answering in about 300ms. The switch holds a connection for roughly as long as a payment takes and everything moves.
B slow · Degraded. Bank B is now taking 8 seconds instead of 300ms. Payments to Bank B are slow, which is unavoidable. Nothing else is affected yet.
B timing out · Down. Bank B is timing out at 30 seconds and requests are piling up against it. Because the switch uses one shared connection pool, those stuck requests occupy every worker. Payments to Bank A, which is perfectly healthy, now fail too. One bank has taken down the whole rail.
bulkheaded · Degraded. Give every bank its own bounded pool. Bank B can fill its own pool and no other. Add a circuit breaker per bank: after a run of failures, fail fast for that destination instead of waiting 30 seconds to find out what you already know. Payments to Bank B still fail, which is honest, and payments to everyone else keep working.
The general form of that fix is worth naming, because it comes up in every design with
third party dependencies. A shared resource pool converts one dependency's failure into
everyone's failure. Bounded per dependency pools, plus a breaker that fails fast, converts
it back into a local failure.
The payer bank confirms the debit. The payee bank returns an error. What now?
They have gone straight to the hard case. Everything before this was optional.
You
The transaction is in DEBITED, which means the switch owns an obligation. There is no rollback available, since the debit committed inside a bank we do not operate, so we compensate: issue a reversal referencing the original transaction id, which appends the opposite ledger entries and returns the money. The original debit stays in the ledger, because it happened, and the reversal is a separate recorded fact.
Uses the word compensate and explains why rollback is not on the table. Also gets the audit property right without being asked.
Interviewer
And if the debit request times out with no response at all?
The real question. Most candidates answer "retry" and lose the round here.
You
Then I do not know whether the money moved, so I must not retry the debit. I would call the bank’s status endpoint with the same transaction id and keep asking with backoff until I get a definite answer. Retrying the debit risks taking the money twice, and a customer debited twice is a much worse outcome than a customer waiting thirty seconds.
Names the specific harm of the wrong choice. That is what makes it sound like a judgement rather than a rule someone memorised.
Interviewer
What if the bank never answers?
Pushing to see whether the answer has a floor.
You
Then it stays in DEBITED and a sweeper picks it up. I would index state together with the timestamp of the last state change so the job can find everything stuck past a threshold cheaply. It keeps status checking, and past a longer deadline it raises a reversal and flags the transaction for the daily reconciliation. What I will not do is let it sit there silently, because that is a customer whose money has vanished and nobody has noticed.
The sweeper plus the index is the detail that shows this has been operated and not just drawn.
Interviewer
How do you know your ledger and the bank’s ledger agree?
Checking whether reconciliation exists in your mental model at all.
You
I assume they do not. We exchange settlement files daily and match line by line. The category that matters is transactions we marked successful that the bank has no record of, since we have told a customer something that is not true. Those go to a human. The reverse case, where they completed something we never got a response for, is mostly caught by status checks during the day.
Starting from "I assume they do not agree" is the correct instinct and it takes one sentence to signal.
Interviewer
What would you want to measure once this is live?
Open ended, and a chance to be honest about limits.
You
Success rate split by bank, because an overall number hides the one destination that is broken. Time spent in DEBITED at p99, since that is customer money in flight. Count of reversals, and separately count of transactions the sweeper had to resolve, because that number rising is the earliest signal something upstream is degrading. I have not run a rail at this scale, so I would want to see a week of real data before I picked thresholds for any of them.
Metrics chosen from the failure modes discussed, and an honest limit at the end. Both are scoreable.
1. The debit request to the remitter bank times out. What is the correct next action?
2. Why is two phase commit the wrong tool across two banks?
3. Bank B starts timing out and payments to healthy Bank A begin failing too. What is the cause?
Worth memorising
Tens of thousands of payments a second, each one a two phase movement between two separate companies.
Four ledger writes per payment, so the ledger runs at four times the payment rate.
A timeout is a third outcome, not a failure. Resolve it by asking the bank, never by guessing.
You cannot roll back another company's database. You undo a payment with an opposite payment.
Say this in 60 seconds
This is two transactions at two organisations that cannot share a transaction, so the design is a saga with compensation rather than two phase commit, because no bank will hold locks waiting on another bank. A central switch owns routing and the state machine: resolve the payee address, debit the remitter, credit the beneficiary, and write every state change durably before attempting the action it describes. The state that matters is DEBITED, meaning money has left and not arrived, and everything else exists to make sure nothing stays there. On a timeout I never retry the debit, I status check the same transaction id, because a retry can debit twice. If the credit genuinely fails I append a reversal rather than editing anything, since the ledger is double entry and append only. A sweeper resolves anything stuck, and a daily reconciliation against the bank's own file catches what is left. The failure I would design for hardest is one slow bank filling a shared connection pool and taking down payments to every other bank, which is a per bank pool and a circuit breaker.