A digital wallet is a balance that lives inside an app. Asha adds ₹2,000 from her bank through UPI, pays ₹120 at a tea stall by scanning a QR code, sends ₹500 to her friend Ravi, who uses the same wallet, and moves what is left back to her bank at the end of the month. In India these are prepaid payment instruments (PPIs) regulated by the RBI — the wallets inside Paytm, PhonePe or Amazon Pay; elsewhere, a PayPal or Venmo balance. On screen it is one number. Behind it is a small bank without loans: it must never let two taps spend the same rupee, never lose a top-up that already left someone’s bank, move money between wallets on different database shards, absorb thousands of payments a second into one merchant’s wallet during a sale, and prove every night that the real money in escrow covers every balance it shows.
Understand the problem
First pin down the kind of wallet. A closed-loop balance — store credit spendable only with its issuer — is not a payment system needing RBI approval. A wallet that works at many merchants, sends money to people and withdraws to a bank is a licensed PPI, and that is the product here. It is also not UPI: UPI moves money between two bank accounts and holds nothing; a wallet holds stored value.
The most useful picture is where the money physically sits. When Asha adds ₹2,000, real rupees leave her bank and arrive, through a payment gateway, in an escrow account the wallet company keeps at a bank. Her balance is not money; it is a record that the company owes her ₹2,000. RBI’s Master Directions on PPIs require a non-bank issuer to keep outstanding balances in escrow with a scheduled commercial bank, and the escrow may not end any day below all outstanding balances plus payments due to merchants.
So there are two kinds of movement. Top-ups, withdrawals and merchant settlements move real money, through a bank, with a delay. P2P transfers and in-wallet merchant payments are pure bookkeeping, which is why they can be instant — and why the bookkeeping must be perfect.
Functional requirements
- Users should be able to add money to their wallet from a bank account through UPI or netbanking, or from a card, via a payment gateway.
- Users should be able to pay merchants and send money to other wallet users, by scanning a QR code or choosing a contact.
- Users should be able to withdraw their balance to their own bank account.
- Users should be able to see their balance and full transaction history, and download statements.
Every one of these is subject to the limits of the user’s KYC tier, which the regulator sets and the system must enforce exactly (deep dive below).
Non-functional requirements
- Money is never created or lost. Every movement is a balanced ledger entry, no user wallet goes below zero, and a retried request moves money at most once.
- Latency. Pay and send: p50 under 150 ms, p99 under 500 ms on our side, excluding PIN entry. Balance reads: p99 under 50 ms. A confirmed top-up credited within 2 s of the gateway’s confirmation at p99.
- Scale. 500M registered wallets, 150M monthly active, 40M daily active; 60M money movements a day, with festival-sale openings at 15× the average — about 10,400 a second. The busiest merchant can take about 1,900 payments a second.
- Availability. 99.99% for pay and send, about 4.3 minutes a month (0.01% × 30 days × 1,440 minutes). When forced to choose, refuse a debit rather than risk an overdraft.
- Consistency. Strong where money is decided: a debit sees every earlier committed movement on that wallet. History, alerts and analytics may trail by seconds.
- Durability and audit. Commits wait for a synchronous standby, so no acknowledged movement is lost. The RBI directions require transaction logs kept at least ten years and statements for at least the past six months.
- Compliance. KYC-tier limits enforced exactly, two-factor authentication on every debit, an alert after each transaction showing the balance left, and escrow covering every balance at day end.
In the interview
Ask three questions before drawing: “Are we the licensed issuer holding the money, or an app on a bank’s wallet?”, “Which KYC tiers exist?” and “Must a P2P transfer be instant, or may it show ‘processing’?” The first decides whether escrow and reconciliation are yours; the second, your limit rules; the third, how hard cross-shard transfers are.
Out of scope
- Verifying identity. Document checks and video KYC happen elsewhere; we receive a tier.
- Card and PSP plumbing. Authorisation, 3-D Secure and gateway routing belong to the payment system; a top-up is one of its payments.
- Ledger internals. Journal entries, historical balances and audit proofs are covered by the double-entry ledger; here we use it as a component.
- Credit and interest. No lending or buy-now-pay-later. PPI balances earn no interest under RBI rules anyway.
- Merchant onboarding and the rewards engine. Cashback appears only as a follow-up question.
The set-up
Plan the approach
Say the shape early: “a ledger with a product on top — balances are projections of immutable entries, and every flow is a small state machine around them.” Satisfy the four requirements on one PostgreSQL database, then spend the deep dives on what breaks at scale: races on one balance, transfers across shards, hot merchant wallets, top-ups that succeed late, exact limits, and fraud checks inside a latency budget.
Core entities
- Wallet.
w_asha: owner, kind (user or merchant), KYC tier, status (active, frozen, inactive, closed), balance in paise as a 64-bit integer (₹1,380 is138000, never a float), a version and its shard. - Movement. One per business action — top-up, merchant payment, P2P transfer, withdrawal, refund, reversal: type, from and to, amount, status, and the caller’s idempotency key with a request hash.
- Ledger entry and postings. Each movement books balanced journal entries. A wallet is money the company owes — a credit-normal liability — so a top-up credits it and a spend debits it. The double-entry ledger explains the accounting; here postings are rows we never update.
- Top-up and withdrawal. Amount, status, the gateway’s order and payment IDs or the bank’s reference (UTR), and any limit headroom reserved.
- Limit usage. Per wallet, metric and period:
(w_asha, load, 2026-10), withusedandreservedamounts. - Beneficiary and device. Saved payees with the time they were added (for cooling periods), and the public key of the phone the app is bound to.
API
Add money. The app generates the idempotency key once per tap of “Add money” and reuses it on every retry. The amount comes from the server-side record, never from a redirect.
POST /v1/topups
Idempotency-Key: 6f1c2a9e-3b7d-4e0a-9c51-2d8f0b7a4e13
X-Device-Signature: <signature over the request, made with the bound device key>
{ "amount": 200000, "currency": "INR", "method": "upi_intent" }
{
"id": "tu_9K2",
"status": "pending",
"amount": 200000,
"next_action": { "type": "upi_intent", "uri": "upi://pay?pa=wallet@bank&am=2000.00&tr=tu_9K2&cu=INR" },
"expires_at": "2026-10-27T10:10:00+05:30"
}
Pay or send. Merchant payments use the same shape with a QR payload instead of a contact.
POST /v1/transfers
Idempotency-Key: 0b3e8c47-91d2-4f6a-a8e5-7c2d14f9b630
X-Device-Signature: <signature>
X-Second-Factor: <PIN proof or biometric assertion>
{ "to": { "wallet": "w_ravi" }, "amount": 50000, "note": "dinner" }
{ "id": "tr_77", "status": "completed", "amount": 50000, "balance_after": 138000, "wallet_version": 913 }
The status may be processing if the receiving side has not answered within about 300 ms. Errors are explicit: 422 insufficient_funds, 422 limit_exceeded with the amount still allowed, 403 step_up_required, and 409 when a key is reused with a different body.
Withdraw and read.
POST /v1/withdrawals { "amount": 100000, "bank_account_id": "ba_12" }
GET /v1/wallet balance, version, KYC tier, limits left this month
GET /v1/wallet/transactions?limit=20&cursor=MjAyNi0xMC0yN1QxMDo1MjowMXx0cl83Nw
GET /v1/wallet/statements/2026-09 monthly statement (PDF)
Gateways call POST /internal/gateways/{name}/webhooks with signed events.
Back-of-envelope estimates
These numbers decide the shard count and expose the two hard spots (method: back-of-envelope estimation).
Movements. 60M a day: 60,000,000 ÷ 86,400 ≈ 694/s, call it 700. A festival-sale opening runs at 15× that: 694 × 15 ≈ 10,400 movements/s. At the peak, merchant payments are 60% (6,240/s), P2P transfers 20% (2,080/s), top-ups 15% (1,560/s), and withdrawals, refunds and cashback 5% (520/s).
Write transactions and shards. Wallets are spread across shards by a hash of their ID. With 16 shards, two random wallets sit on different shards 15 times in 16, and then a payment is two local transactions: debit on one shard, credit on the other. Top-ups (create, then credit) and withdrawals (debit, then the bank’s answer) are also two. So 0.8 × (1 + 15/16) + 0.2 × 2 ≈ 1.55 + 0.4 ≈ 2 transactions per movement, and 10,400 × 2 ≈ 20,800 transactions/s at the peak. Each touches five or six rows and waits for a synchronous standby; budget about 1,500 a second per primary. 20,800 ÷ 1,500 ≈ 13.9, so 16 shards, each near 1,300/s at the peak.
Postings. A local transfer has two postings; a cross-shard one is two entries through a pair of clearing accounts — four postings — and a fee adds one. Call it 4 postings per movement: 60M × 4 = 240M postings a day, and 10,400 × 4 ≈ 41,600 postings/s at the peak.
Storage. A posting with its indexes is about 200 bytes: 240M × 200 B = 48 GB a day. Movement rows with their status history are about 500 bytes: 60M × 500 B = 30 GB a day. Together 78 GB a day, about 28.5 TB a year before replication, roughly 1.8 TB per shard. Ten years is about 285 TB, so keep a year online and archive older months as compressed columnar files in object storage.
History. Each movement shows up in two histories: 60M × 2 × 300 B = 36 GB a day, about 13 TB a year in the history store.
Reads. 40M daily users × 10 balance or history views = 400M a day ≈ 4,630/s, and 15× that at the peak: ≈ 69,000 reads/s, over three times the write rate.
Idempotency keys. One per client-initiated movement, kept 7 days: 60M × 7 = 420M keys × 150 B ≈ 63 GB, about 4 GB per shard.
Top-ups in flight. By Little’s law, 1,560 top-ups/s × about 30 s to approve in a UPI app ≈ 47,000 pending top-ups at the peak. One large Indian gateway documents that under 0.5% of payments are authorised late; at 9M top-ups a day that is up to 9,000,000 × 0.005 = 45,000 a day whose first answer was wrong.
The hot merchant. One merchant taking 30% of merchant payments at the peak gets 6,240 × 0.3 ≈ 1,900 credits/s. If each transaction holds the row lock about 2 ms (including the standby), one row tops out at 1 s ÷ 2 ms = 500 updates/s.
Escrow. 150M active wallets × ₹400 average balance = ₹60 billion, or ₹6,000 crore held for customers.
Tip
Per-wallet traffic is tiny. What shapes this design is the share of movements that cross shards and the few accounts that receive a large share of all traffic.
High-level design
Build the four requirements on one PostgreSQL database behind a wallet service; shards come in the deep dives.
Users should be able to add money to their wallet
A top-up is a payment to the wallet company, followed by a wallet credit. The payment half is the payment system in miniature, so lean on a payment gateway: it offers UPI, cards and netbanking and settles the money into escrow.
- The app calls
POST /v1/topupswith an idempotency key. The wallet service checks Asha’s KYC-tier limits, reserves ₹2,000 of headroom, and saves the top-up aspending. - It creates a gateway order with
tu_9K2as the reference, so the gateway’s records always join back to ours, and returns the UPI intent. - Asha approves in her UPI app; her bank debits her.
- The gateway sends a signed webhook. In one transaction the wallet service marks the top-up
succeeded, posts the entry (debit gateway receivable, credit Asha’s wallet), converts the reservation and writes an outbox row. A unique constraint on the gateway’s payment ID makes a duplicate webhook a no-op.
Never credit because the app says “success” after the UPI redirect: the app is both unreliable and possibly hostile. Credit only on a server-to-server confirmation, and poll the gateway’s status API with backoff when the webhook is late. What to do when even the gateway does not know is a deep dive.
Users should be able to pay merchants and send money to other wallet users
A P2P transfer and a QR payment are the same operation: move N paise from one wallet to another. A merchant is a wallet of kind merchant; its QR code carries the wallet ID and sometimes an amount. On one database it is one transaction.
- The app signs the request with a phone-bound key and adds a second factor (PIN or biometric); the gateway authenticates and rate-limits.
- The wallet service applies tier rules — a small-KYC wallet may pay merchants but not people — and asks the risk service for a verdict.
- One transaction claims the idempotency key, debits Asha only if her balance covers it, credits Ravi, writes postings, bumps limit counters and adds an outbox row.
- The response carries
balance_after. The outbox drives SMS and push alerts; RBI’s directions require an alert after each transaction showing the balance left.
Lock the two wallet rows in a fixed order — lower wallet ID first. Otherwise Asha paying Ravi while Ravi pays Asha can deadlock, each holding one row. PostgreSQL detects that and aborts one, but its documentation gives the better fix: take locks in a consistent order.
Users should be able to withdraw their balance to their own bank account
A withdrawal is a top-up in reverse: the money leaves through a bank that may be slow, time out, or return the payment hours later. Model it as a state machine and keep the money visible at every step.
- Requested. The RBI directions allow full-KYC wallets to send money “back to source” or to the holder’s own verified bank account; a small-KYC wallet can only be closed, its balance returned to source.
- Debited. One transaction moves the amount into a
payouts_in_flightaccount, so it cannot be spent twice while the bank works. - Sent to bank. A payout worker calls the bank’s IMPS payout API with the withdrawal ID as reference. A timeout means unknown, not failed.
- Paid or returned. Store the bank’s UTR and clear the in-flight account, or post a reversing entry that credits the wallet back.
Users should be able to see their balance and full transaction history
The balance a user sees and the balance a debit checks need different guarantees. The debit check runs inside the write transaction on the primary, so it is exact. The home screen can read a replica, tagged with the wallet version; after a payment the app shows balance_after and ignores older versions — read-your-writes without sending every read to the primary.
History is a different shape: one movement, two viewpoints (“Paid Ravi ₹500”, “Received ₹500 from Asha”). Build it as a read model, the query side of CQRS.
- One row per party. The history builder writes one row into each affected wallet’s history, in a store partitioned by wallet ID, newest first. Redelivered events are skipped by movement ID (the inbox pattern).
- Cursor pagination. “Show 20 more” passes a cursor of the last row’s time and movement ID, so the store seeks straight there instead of walking past an offset. Modern Treasury recommends cursors for ledger history for this reason (offset vs keyset pagination).
- Statements. A monthly job renders statements. RBI requires at least six months of statements showing date, amount, balance and description.
You now have a working wallet, correct on one database at modest traffic. The deep dives are about what breaks on the opening morning of a festival sale.
Deep dives
How do we make sure concurrent payments never create or lose money?
Ravi has ₹1,000. Two ₹800 payments from his wallet arrive in the same millisecond — say, from his phone and his tablet. Exactly one may succeed.
Bad: read, check, write. Read the balance, check it in code, write the new value. Both requests read ₹1,000, both pass, both write ₹200: ₹1,600 left a wallet that held ₹1,000, and the escrow no longer covers what the ledger promises. Writing balance = balance - 800 without a condition just ends at −₹600 instead. This is the lost update problem.
Good: one conditional, atomic update. Put the check inside the write: UPDATE wallets SET balance = balance - 80000 WHERE id = 'w_ravi' AND balance >= 80000 RETURNING balance. Zero rows back means insufficient funds. It is safe because under PostgreSQL’s default Read Committed level the second UPDATE waits for the first to commit, then re-evaluates its WHERE clause against the new row version — and 200 ≥ 800 is false. SELECT … FOR UPDATE then a check in code works too, with one more round trip under the lock. An optimistic version check also works, but Modern Treasury notes that version locking starves busy accounts, which is why it lets callers assert a balance condition instead (optimistic vs pessimistic locking). You gain: no overdraft, in one statement. You pay: the balance is the only record of what happened; if the history row is written separately and fails, nobody can explain the number.
Great: ledger first, with the balance as a projection. Every movement inserts immutable postings, and the balance is a cached sum of them, updated in the same transaction with the same condition. It can always be recomputed, and a correction is a new reversing entry, never an edit. Asha’s ₹500 transfer, on her shard:
BEGIN; -- one local transaction on Asha's shard
-- 1. Claim the idempotency key. A retry hits the unique index on (user_id, idem_key)
-- and returns the saved result instead of moving money again.
INSERT INTO transfers (id, user_id, idem_key, req_hash, from_wallet, to_wallet, amount, status)
VALUES ('tr_77', 'u_asha', '0b3e8c47-91d2-4f6a-a8e5-7c2d14f9b630', 'h_91c2',
'w_asha', 'w_ravi', 50000, 'debited');
-- 2. Debit only if the money is there. Zero rows returned -> ROLLBACK, insufficient funds.
UPDATE wallets
SET balance = balance - 50000, version = version + 1
WHERE id = 'w_asha' AND status = 'active' AND balance >= 50000
RETURNING balance, version; -- 138000, 913
-- 3. Count it against this month's transfer limit, with the same pattern.
UPDATE limit_usage
SET used = used + 50000
WHERE wallet_id = 'w_asha' AND metric = 'p2p_other' AND period = '2026-10'
AND used + reserved + 50000 <= 1000000; -- ₹10,000 cap; 0 rows -> ROLLBACK
-- 4. Immutable postings that sum to zero, and the event for everyone downstream.
INSERT INTO postings (entry_id, account, amount, account_version) VALUES
('je_77a', 'wallet:w_asha', 50000, 913), -- debit: we owe Asha less
('je_77a', 'clearing:s03->s11', -50000, NULL); -- credit: in flight to shard 11
INSERT INTO outbox (topic, msg_key, payload)
VALUES ('transfers', 'tr_77', '{"step":"credit","to":"w_ravi","amount":50000}');
COMMIT;
A nightly job checks every balance against the sum of its postings — the drift check Modern Treasury describes for its cached balances; for a wallet, drift means freeze and page. You pay: about four postings per movement (48 GB a day) and a multi-statement transaction per write. You gain: an audit trail by construction, rebuildable balances, and reversals that keep history intact. Holds — a cab ride that reserves ₹300 and charges ₹264 — are the ledger’s two-phase pending entries, giving available = posted − pending; the ledger design covers them.
How do we transfer money between wallets on different shards?
With 16 shards keyed by wallet ID, Asha lives on shard 3 and Ravi on shard 11; 15 of every 16 movements cross shards like this. The one in 16 that does not commits locally — keep that fast path. For the rest, no single database transaction spans both rows.
Bad: two-phase commit across the shards. A coordinator asks both shards to prepare, then to commit. It is atomic, but both rows stay locked across extra round trips, and if the coordinator dies after the prepare they stay locked until it recovers. Thousands of payments a second would pay this tax, and availability becomes the product of two shards and a coordinator (2PC vs sagas).
Good: a saga through clearing accounts. Shard 3 debits Asha and credits a clearing account for shard 11 in one local transaction, then publishes an event; shard 11 debits its clearing account from shard 3 and credits Ravi. If shard 11 refuses — Ravi’s wallet is frozen, or the credit would break his KYC cap — a compensating entry refunds Asha. Each shard always balances on its own, and a clearing pair that stays non-zero is a built-in alarm; the double-entry ledger walks through the postings. What you pay with a purely event-driven (choreographed) saga: “where is my ₹500?” means reconstructing a story from events, and there is a nasty race. A sweeper decides a transfer is stuck and refunds Asha while the credit is still queued for shard 11; both land, and ₹500 has been created.
Great: an orchestrated state machine, fenced on the receiving side. Make the transfer an explicit object with a state, stored on the sender’s shard in the same transaction as the debit. Every transition is a conditional update (… SET status = 'credited' WHERE status = 'debited') plus one balanced local entry, keyed by (transfer_id, step) so a repeated step is a no-op.
The piece that removes the race is the fence on shard 11. It keeps a table of inbound transfers whose primary key is the transfer ID, and both possible outcomes are an insert into it:
-- On shard 11, in the same transaction as Ravi's credit postings (or alone, to reject):
INSERT INTO inbound_transfers (transfer_id, outcome) VALUES ('tr_77', 'credited')
ON CONFLICT (transfer_id) DO NOTHING; -- 0 rows: an outcome already exists; read it and return it
Whichever arrives first — the credit, or a sweeper’s “reject if you have not credited” — wins; later requests read the stored outcome. Shard 3 refunds Asha only after it reads rejected. A timeout tells it nothing, so the transfer stays debited, the app shows “processing”, and the sweeper keeps asking. Normally the wallet service calls shard 11 right after shard 3 commits, so Asha sees “Sent” within 20–40 ms; the outbox relay and sweeper are the backstop. RBI’s turnaround-time circular says an on-us PPI transfer that debits the sender but does not credit the beneficiary must be reversed within T+1 day, with ₹100 a day of compensation after that — so anything not terminal within 15 minutes pages a person.
Idempotency, end to end. The app makes one key per tap of “Send”, outside its retry loop. The server maps (user, key) to tr_77 in the same transaction as the debit and saves the first response. Stripe’s write-up covers the three cases: a connection that failed before the server saw the request is processed fresh; a server that failed mid-way rolled back, so the retry starts clean; a lost response is replayed. Like Stripe, hash the request body and refuse a reused key with different parameters. Stripe prunes keys after 24 hours; a phone can be offline overnight, so keep them 7 days. Every downstream step and ledger entry is keyed by the transfer ID.
| Two-phase commit | Choreographed saga | Orchestrated state machine + fence | |
|---|---|---|---|
| Atomic across shards | Yes | Eventually, via compensation | Eventually, with one decision per step |
| Locks held | Across round trips and the coordinator | Local only | Local only |
| Failure while in flight | Rows stay locked until the coordinator recovers | Money waits in clearing; compensation can race a late credit | Money waits in clearing; the receiver’s outcome row fences the race |
| “Where is my ₹500?” | Trivial | Rebuild from events | Read one transfer row |
| What you run | A coordinator and its log | Event handlers per step | A transfer table, a relay and a sweeper |
In the interview
Say the invariant out loud: “Money in flight is never invisible. It sits in a clearing account, the transfer row says which step it has reached, and the receiving shard decides credit or reject exactly once. The sender refunds only on a recorded rejection, never on a timeout.”
How do we handle a merchant wallet that receives thousands of payments a second?
On a sale morning, one merchant — a ticketing site at the tatkal hour, a food-delivery app with a festival offer — can take about 1,900 credits a second. One row tops out near 500 updates a second, and Modern Treasury reports authorizing ledgers degrading around 100 entries a second on a single account.
Bad: one balance row for the merchant. Correct and simple, but every payment queues for that row’s lock. Demand is almost four times capacity, so the queue grows by about 1,400 transactions a second until the shard’s other customers time out. A bigger machine does not help: the limit is lock hold time, not CPU.
Good: one bucket per shard. Split the merchant’s wallet into 16 sub-accounts, one on each shard, and credit the bucket on the payer’s shard. Each takes 1,900 ÷ 16 ≈ 119 credits a second, under a quarter of a row’s capacity — and the payment becomes a single local transaction, with no cross-shard saga. What you pay: the balance is a sum of 16 rows on 16 shards, so debits — the daily settlement, refunds — need a cross-shard read that must not race the credits.
Great: insert-only credits and a lower-bound balance. A credit never needs a balance check — adding money cannot overdraw — so it can be a plain insert of postings with no row lock. A roll-up worker folds new postings into each bucket once a second: 16 row updates a second instead of 1,900. Only credits ever wait to be folded, so the folded balance is never more than the true balance, and debits checked against it can never overdraw. Dashboards add the unfolded postings for an exact figure. What you pay: debits see money up to a second late, and the roll-up worker needs a lag alarm. The double-entry ledger uses the same bucket-and-fold tools for its busiest system accounts.
Do not do this for user wallets
Bucketing and folding are for accounts that are mostly credited. A user wallet is mostly debited, every debit needs the exact balance, and its traffic is a few movements a day. Give it one row and the conditional update.
One refund subtlety: RBI’s directions say a refund of a failed or cancelled PPI-paid transaction must be credited immediately, even above the wallet’s limits. So refunds credit the customer without the load-cap check.
How do we make sure every top-up that took the customer’s money reaches the wallet?
The angriest support ticket is “money left my bank, my wallet shows nothing”: a lost webhook, an app killed mid-flow, or — most stubbornly — a gateway that does not know yet.
Bad: trust the redirect. Credit when the app returns with “success”. Anyone who can edit an HTTP request can mint money, and a user who closes the app at the wrong moment is never credited.
Good: webhooks plus polling, credited once. Credit only on a signed server-to-server event, poll when it is late, dedupe by payment ID, and give up when the gateway gives up. It misses the long tail. One large Indian gateway documents that a payment with no bank response is marked failed after 10 minutes, but the gateway keeps polling the bank for 3 days and moves late successes to authorized — under 0.5% of payments, up to 45,000 of our top-ups a day. Stop listening at 10 minutes and those users have lost money until they complain. The same gateway auto-refunds authorised payments not captured within 3 days, so top-ups must auto-capture.
Great: a top-up state machine with a long tail, and three-way reconciliation. Give top-ups states that admit uncertainty: pending, succeeded, failed_unconfirmed (“if money was debited, it will be added or refunded automatically”), and only later failed_final or refunded_to_source. Keep listening for the gateway’s full 3-day window. On a late success, re-check limits: credit if they allow it, otherwise refund to source. Either way, tell the user.
Then reconcile daily against two outside sources of truth:
- Our ledger — top-up entries, and the gateway receivable account, which should return to zero once the gateway settles.
- The gateway’s settlement report — one line per payment with amount, fee and batch.
- The escrow bank statement — one credit per batch, plus payouts going out.
Each row lands in one bucket: matched; at the gateway but not with us (the late-success path); with us but not at the gateway (freeze and investigate — a bug or a forged event); or a mismatched amount. A receivable still non-zero after its settlement date is an alarm — the payment system’s reconciliation, pointed at top-ups.
Finally, the escrow check: at each day’s end, compare all wallet balances plus merchant dues, from the ledger, with the escrow balance. RBI requires escrow to be credited for a load by close of that business day, never to end a day below balances plus merchant dues, and any shortfall to be reported immediately. With ₹6,000 crore outstanding, this is an automated job with a person on call, not a spreadsheet.
How do we enforce KYC-tier limits exactly?
The limits come straight from the RBI directions. These are the two tiers to model:
| Small (minimum-detail) PPI | Full-KYC PPI | |
|---|---|---|
| To open | Mobile number verified by OTP, plus a self-declared ID document number | Full KYC; video KYC is allowed |
| Most it can hold | ₹10,000 at any time | ₹2,00,000 at any time |
| Loading | ₹10,000 a month and ₹1,20,000 a financial year | Within the balance cap; cash loads at most ₹50,000 a month |
| Pay merchants | Yes | Yes |
| Send to other people | No | Up to ₹2,00,000 a month per pre-registered beneficiary; ₹10,000 a month in all other cases |
| Withdraw | No; close it and the balance goes back to source | Back to source or to the holder’s own verified bank account; non-bank cash-out at most ₹2,000 a time and ₹10,000 a month |
| Time limit | Must become full-KYC within 24 months, or it takes no further credits | None |
| UPI interoperability | Not required | Mandatory for wallets |
Bad: compute usage from history on each request. SUM(amount) over this month’s top-ups is slow for active wallets and racy: two top-ups that start together both see ₹8,500 used and both pass.
Good: counters in Redis. INCRBY is atomic and fast, but not in the same transaction as the money. A rollback after the increment wrongly blocks the user; a crash after commit but before the increment silently exceeds the cap — a regulatory breach nobody notices until an audit.
Great: counter rows on the wallet’s shard, updated in the same transaction. Store limit_usage(wallet_id, metric, period, used, reserved) and update it with the same conditional pattern as the balance (… WHERE used + reserved + :amount <= :cap). Three details make it exact:
- Periods are part of the key. October’s row is
2026-10, the financial year’sFY2026-27(April to March). Nothing resets at midnight; on 1 November the service reads a new row. Treat “month” as the calendar month in IST, and confirm that with compliance. - Top-ups reserve headroom. Creating a top-up adds to
reserved; success moves it toused, failure releases it. Two parallel top-ups cannot both pass, and money that has left the bank never fails at credit time because of a limit. - Some credits bypass caps by rule. Refunds of PPI-paid transactions, and reversals of unauthorised transactions, must be credited even above the limits. Small wallets carry a
no_credit_afterdate 24 months from issue unless upgraded.
You pay one or two extra row updates per movement and a few rows per wallet per month. You gain limits exactly as consistent as the balance, because they are updated with it. Customer-chosen caps, which RBI also requires you to offer, are just more rows.
How do we stop fraud without slowing down every payment?
Wallet fraud has recurring shapes: a stolen or SIM-swapped phone, an OTP phished for account takeover, stolen cards loaded and moved out to a mule’s bank account, rings of new wallets funnelling money to one place. The defence must sit on the hot path without breaking the 500 ms budget.
Bad: a heavyweight model inline on every payment. A 200 ms scoring call blows the p99 budget and becomes a hard dependency: when it slows, payments stop. Scoring only after the fact is the opposite mistake — the money has already left.
Good: inline rules only. Velocity limits, amount thresholds, new-device and new-payee checks. Fast and explainable, but blunt: fraudsters learn the thresholds, and honest users trip them.
Great: layers, each with a budget and a failure mode.
- Who and which device. The app creates a key pair on install, bound to the user after a SIM and OTP check; every debit is signed with it, plus a PIN or biometric. RBI requires two-factor authentication on every wallet debit, and a phished OTP alone cannot produce the bound device’s signature.
- Inline rules on precomputed features, inside 60 ms. Device age, payee age, amount against the user’s usual range, transfers in the last hour — read from a feature store the event stream keeps warm. Verdict: allow, step-up, hold or deny. If risk times out, small payments to known payees pass on rules alone; large ones are held.
- Regulatory rules as code. Velocity checks per day and per beneficiary, a daily cap on new beneficiaries with an alert for each, and a cooling period before transfers and withdrawals after a wallet is opened, loaded or given a new beneficiary.
- Models over the stream, after commit. Graph and behaviour models score each event within seconds and can freeze a wallet or hold withdrawals. The cooling period delays the exit to a bank, so this slow layer usually acts before the money is gone.
You gain fraud checks that never take payments down, and a smart layer that still acts before money leaves. You pay for a feature store and a stream pipeline, rules to tune, and some honest users stepped up or held.
The final design
Put the pieces together. The hot path is the gateway, the wallet API, the risk check and one or two shard transactions; top-ups, payouts, history and reconciliation hang off the event stream and the banks.
Follow Asha’s ₹500 to Ravi, and her ₹2,000 top-up, through the system:
- Authenticate. The app signs the request with its bound device key, with PIN proof and an idempotency key; the gateway checks them and rate-limits.
- Decide. The wallet API applies tier rules and asks the risk service for a verdict within 60 ms; on a timeout, small payments to known payees continue on rules alone.
- Debit. One local transaction on Asha’s shard claims the key, debits her if the balance covers ₹500, bumps limit counters, and writes postings, the transfer row and an outbox row. If Ravi shares the shard, his credit is in it too.
- Credit or reject. Otherwise the orchestrator asks Ravi’s shard, which records exactly one outcome per transfer ID. A rejection becomes a refund; a timeout becomes “processing” and a sweeper retry. Big merchants’ buckets on Asha’s own shard skip this step.
- Top up. The top-up service reserves headroom, creates a gateway order, and credits the wallet once per payment ID when a webhook or poll confirms it; late successes are handled for 3 days.
- Pay out. The payout service turns withdrawals and merchant settlements into IMPS or NEFT payouts from the escrow bank, each keyed by its own ID.
- Fan out. The event stream feeds history, alerts with the remaining balance, fraud models that can freeze a wallet, and the ten-year archive.
- Reconcile. Daily, ledger postings are matched with the gateway’s settlement file and the escrow statement, and escrow is checked against all balances plus merchant dues.
Check it against the requirements. Money is never created or lost: every balance change is a conditional update in the same transaction as balanced postings, keys stop duplicates, and the receiver’s fence stops a credit and a refund from both happening. Latency: about 190 ms of the 500 ms p99 budget. Scale: 16 shards near 1,300 transactions a second each at the peak, the 1,900-a-second merchant spread over insert-only buckets, and 69,000 reads a second on replicas and the history store. Compliance: limits in the same transaction as the money, 2FA on every debit, alerts, statements, ten-year logs and a daily escrow check.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Problem framing | A balance table with add and subtract | Separates real money (escrow, banks) from ledger money; names KYC tiers | Designs to the regulator’s rules: escrow coverage, reversal deadlines, ten-year logs |
| Balance correctness | Uses a transaction | Spots the lost update; conditional UPDATE … WHERE balance >= amount |
Ledger-first projection with drift checks; holds as two-phase entries; lock ordering |
| Idempotency | Retries “carefully” | Client-generated keys stored with the effect; replay the saved response | Keys end to end (app → transfer → step → ledger entry), parameter hashing, retention tied to retry windows |
| Cross-shard transfers | Not noticed | Saga with compensation | Orchestrated state machine with a receiver-side fence; refund only on a recorded rejection |
| Hot accounts | Not noticed | Sees the single-row limit for a big merchant | Per-shard buckets, insert-only credits, lower-bound balances for debits |
| Top-ups and reconciliation | Credits on the app’s callback | Webhooks plus polling, deduplicated | Late-success handling, three-way reconciliation, escrow end-of-day check |
| Limits and fraud | Limits checked in code | Counters, velocity rules, 2FA | Limits in the same transaction with reservations; layered risk with budgets and failure modes |
| Reads and history | Query the transactions table | Replicas and a history read model | CQRS from the outbox, cursor pagination, rebuildable read models, archival |
Follow-up questions
What if a user says they never made a payment?
Treat it as a regulated workflow, not a support ticket. Block further debits, acknowledge at once with a complaint number, and credit the disputed amount within 10 days of the report — RBI calls this a shadow reversal, allows it to exceed the wallet’s limits, and requires it to be value-dated to the transaction. Final liability depends on fault and how quickly the customer reported, must be settled within 90 days, and the burden of proof is on the issuer. In the ledger it is one entry from a disputes account, later recovered or written off.
What if the wallet must work with every UPI app and QR code?
For full-KYC wallets it is mandatory: RBI requires wallet interoperability through UPI, with the issuer acting as a UPI payment system provider and, as a non-bank, settling through a sponsor bank. An “off-us” payment — Asha paying a merchant whose QR belongs to another provider — debits her wallet into a UPI settlement payable account and goes out over UPI, whose failure TATs then apply. Add the network’s daily settlement as a fourth reconciliation source.
What if marketing wants cashback that cannot be withdrawn?
Give each wallet a promotional sub-balance, credited from a marketing-expense account. Payments spend it first (a product rule), withdrawals use only the main balance, and promotional credits can expire back to marketing. Two warnings: once credited, it is money you owe the user, so it counts in the escrow check; and KYC caps apply to the total, so a ₹50 cashback can be refused for a small wallet already at ₹10,000.
What if a shard’s primary fails in the middle of a transfer?
Commits wait for a synchronous standby, so anything acknowledged survives the failover, and an uncommitted transaction rolls back whole. The app retries with the same idempotency key and either finds the saved result or runs the transfer fresh. Transfers left debited are picked up by the sweeper, and the receiving shard’s replicated outcome table means a retry cannot credit twice. Users on that shard see “processing” for the failover window; the other 15 shards notice nothing unless paying someone on the sick one.
What if a wallet sits unused for a year?
RBI requires wallets with no financial transaction for a year to be made inactive after notice, reactivated only after validation, and reported separately. Validity is at least a year from the last load, with warnings in the 45 days before expiry, and a non-bank issuer may not move an expired balance to profit and loss for at least three years. Index wallets by last financial transaction and let a daily job per shard send notices and flip statuses. The money stays a liability in the escrow check, refundable to a bank account on request.
How to say it in the interview
“I’ll design a prepaid wallet in the Paytm or PhonePe style: add money, pay merchants by QR, send money to friends, withdraw to a bank, about 60 million movements a day, peaking near 10,000 a second.
First, where the money is: the real rupees sit in an escrow account at a bank, and the wallet balance is a ledger record of what we owe each user. Top-ups and withdrawals move real money through a gateway and a bank, so they are asynchronous and reconciled. Transfers and merchant payments only move ledger money, so they can be instant — but they must be perfect.
Every movement is a balanced journal entry written in the same database transaction as a conditional balance update — debit only if the balance covers it — plus the idempotency key and an outbox row. That makes overdrafts and double spends impossible on one shard.
Wallets are spread across 16 PostgreSQL shards. A transfer across shards is an orchestrated state machine: debit locally on the sender’s shard into a clearing account, then ask the receiver’s shard to credit or reject, which it records exactly once per transfer ID. The sender refunds only on a recorded rejection, never on a timeout, and anything stuck pages us long before RBI’s T+1 reversal deadline.
Big merchants would melt a single balance row at 1,900 credits a second, so they get a bucket on every shard with insert-only credits folded once a second; debits check the folded balance, which can only be low, never high.
Top-ups stay pending until a webhook or a poll confirms them, keep listening for late successes for three days, and are reconciled daily against the gateway’s settlement file and the escrow statement. KYC limits are counters in the same transaction, with reservations for top-ups. Fraud is layered: device binding and 2FA, inline rules within 60 milliseconds, and slower models after commit that can freeze withdrawals. History is a CQRS read model with cursor pagination.”
Concepts used in this design
- Idempotency — keys from the app to the ledger entry, so retries never move money twice.
- Lost update problem — the read-then-write race that creates money.
- Optimistic vs pessimistic locking — conditional updates, row locks and why version checks starve busy accounts.
- Transaction isolation levels — why Read Committed re-checks the
WHEREclause after a wait. - ACID transactions — balance, postings, limits and outbox commit together or not at all.
- Sharding a relational database — 16 shards keyed by wallet ID.
- Distributed transactions: 2PC vs sagas — the options for cross-shard transfers.
- Transactional outbox — events that are published if and only if the money moved.
- Idempotent consumers and the inbox pattern — history and alerts that tolerate redelivery.
- Event sourcing and CQRS — balances and history as projections of the ledger.
- Offset vs keyset pagination — cursors for transaction history.
- Webhooks — signed gateway callbacks for top-ups.
- Timeouts, retries and backoff with jitter — polling gateways and banks without a retry storm.
- Contention and double booking — the hot merchant row and how to spread it.
- Strong vs eventual consistency — exact debits, slightly stale history.
- PostgreSQL — row locks, conditional updates and synchronous standbys.
References and further reading
- RBI Master Directions on Prepaid Payment Instruments (MD-PPIs), 2021, as updatedrbi.org.inSmall versus full-KYC PPI limits, the escrow rules for non-bank issuers, ten-year transaction logs, 2FA on debits, velocity checks, cooling periods, refunds above limits, statements, dormancy and the 10-day shadow reversal.
- RBI circular: Harmonisation of Turn Around Time and customer compensation for failed transactions (20 September 2019)rbi.org.inOn-us PPI transfers where the beneficiary is not credited must be reversed within T+1 day, with ₹100 a day compensation after that, paid without waiting for a complaint.
- Designing robust and predictable APIs with idempotency (Stripe blog)stripe.comThe three ways a call can fail, idempotency keys generated by the client and reused on retry, and exponential backoff with jitter.
- Stripe API reference: Idempotent requestsdocs.stripe.comKeys up to 255 characters, the saved first response, parameter comparison on reuse, pruning after 24 hours, and why validation failures and concurrent conflicts are not saved.
- How to Scale a Ledger, Part IV: recording and authorizing (Modern Treasury)moderntreasury.comVersion locking versus balance locking, hot accounts starving version locks, digital wallets as an authorizing use case, and degradation around 100 entries per second on one account.
- How to Scale a Ledger, Part VI: concurrency and performance (Modern Treasury)moderntreasury.comIdempotency keys kept 24 hours, cached balances updated synchronously for balance checks, drift monitoring against the sum of entries, and cursor-based pagination for history.
- PostgreSQL documentation: Transaction isolationpostgresql.orgRead Committed is the default; a second UPDATE waits for the first to commit and then re-evaluates its WHERE clause on the new row version.
- PostgreSQL documentation: Explicit lockingpostgresql.orgRow-level lock modes, deadlocks between two balance updates, and the advice to take locks on several objects in a consistent order.
- Razorpay docs: About payments (payment life cycle)razorpay.comThe created, authorized, captured, refunded and failed states, and automatic refunds of authorised payments not captured within three days.
- Razorpay docs: Late payment authorisationsrazorpay.comA payment with no bank reply is marked failed after 10 minutes, the bank is polled for 3 days, and under 0.5% of payments are authorised late.