Design a digital wallet (Paytm)
The interviewer asks
“Design a prepaid digital wallet like Paytm Wallet or Amazon Pay balance. Users add money from UPI, cards or netbanking, pay merchants, send money to other wallet users, receive cashback and withdraw to their bank. How do you keep every balance correct with millions of concurrent payments?”
What a strong answer looks like
A strong answer notices that, unlike UPI, here we are the bank for the wallet balance: we hold the money (in an escrow account, as a regulated prepaid payment instrument issuer) and we own the ledger. So the core is a double-entry ledger where every movement is a balanced journal between accounts (user wallets, merchant wallets, escrow cash, cashback expense, fees), and wallet debits are atomic conditional updates that never allow a negative balance. Add money is a two-phase flow with the payment gateway (credit only on confirmed payment, idempotent on the gateway reference). Payments and P2P transfers are single-database transactions when both accounts are on the same shard, and a saga with a holding account when they are not. Regulatory limits (KYC tiers: minimum-KYC wallets capped at ₹10,000 balance, full-KYC up to ₹2 lakh) are enforced in the same transaction. Deep dives: the ledger and balance correctness, cross-shard transfers, hot merchant accounts, and the daily invariant that the escrow bank balance equals the sum of all wallet balances.
Concepts to mention
1 Requirements
Functional
- Add money to the wallet via UPI, cards or netbanking through a payment gateway.
- Pay merchants from the wallet (QR, online checkout) and send money to other wallet users.
- Show balance and statement; split balance into main and promotional (cashback) parts.
- Credit cashback and promotional balance, with expiry for promotional money.
- Refund merchant payments back to the wallet.
- Withdraw to a bank account for full-KYC users.
- Enforce KYC limits on balance and monthly loading.
Non-functional
- Balance is never negative and never double-spent; every rupee is traceable in the ledger.
- Wallet payment p99 under 200 ms (no external call on the pay path).
- 99.99% availability for payments; strong consistency for balances.
- Peak ~20k wallet transactions/s during sales and cashback campaigns.
- Daily invariant: escrow bank balance = sum of all wallet balances (+ in-flight).
- Audit trail retained for years; data stored in India.
Out of scope (say it out loud)
- KYC verification process itself (Aadhaar/video KYC).
- Merchant onboarding and acquiring for cards/UPI.
- Credit lines and pay-later products.
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/wallet/topups | Start add money {amount, method}; returns a gateway order; wallet is credited only on confirmed payment. |
POST/v1/wallet/payments | Pay a merchant {merchant_id, amount, order_ref}; Idempotency-Key; returns txn_id and new balance. |
POST/v1/wallet/transfers | P2P transfer {to_user, amount}; Idempotency-Key. |
POST/v1/wallet/refunds | Merchant refunds {txn_id, amount} back to the user's wallet; idempotent. |
POST/v1/wallet/withdrawals | Withdraw to bank (full-KYC only) via payout rails (IMPS). |
GET/v1/wallet/balance | Main + promotional balance. |
GET/v1/wallet/statement?cursor= | Ledger-backed statement. |
4 Data model
accountsPostgres/MySQL sharded by account_idaccount_idowner_idtype (user_main|user_promo|merchant|escrow|cashback_expense|fees|suspense)currencybalance_paisekyc_tierstatusversionBalance column is a cached sum of ledger lines, updated in the same transaction as the lines. CHECK (balance >= 0) for user accounts.
journalsSame shard as the debited accountjournal_idtype (topup|payment|p2p|refund|cashback|withdrawal)idempotency_key (unique)statuscreated_atOne business transaction = one journal.
ledger_linesSame shard, append-onlyline_idjournal_idaccount_idamount_paise (+credit / -debit)balance_afterSum of amounts in a journal = 0. Never updated; reversals are new journals. balance_after makes statements and audits easy.
limitsSame shard as user accountuser_idmonthloaded_paisespent_paisekyc_tierChecked and updated in the same transaction as the debit or credit.
promo_lotsPostgreslot_iduser_idamount_paiseremaining_paiseexpires_atcampaign_idPromotional balance kept in lots so expiry and campaign accounting are exact.
outbox / eventsPostgres → Kafkaevent_idjournal_idtypepayloadNotifications, merchant settlement, analytics and fraud consume these.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Add ₹1,000 via UPI
- 1User taps Add Money; top-up service checks KYC limits (min-KYC: monthly load and ₹10,000 balance cap) and creates a pending topup with a gateway order.
- 2User pays through the gateway (UPI intent); money lands in the wallet issuer's escrow account via the gateway's settlement.
- 3Gateway webhook (verified signature) reports success; top-up service posts a journal idempotent on the gateway payment ID: debit 'gateway receivable' ₹1,000, credit user_main ₹1,000.
- 4The user's balance and monthly loaded amount update in the same transaction; an event triggers a notification.
- 5If the webhook is late, the app polls topup status; a reconciler queries the gateway for pending topups older than a few minutes.
Pay a merchant ₹250 from wallet
- 1App sends POST /wallet/payments with an Idempotency-Key; the wallet service runs a quick risk check.
- 2On the user's shard, one transaction: insert journal (unique idempotency_key), UPDATE accounts SET balance = balance - 250 WHERE account_id = user AND balance >= 250 (fail if 0 rows), insert ledger lines.
- 3Promotional money is used first if the merchant is eligible; lots are consumed oldest-expiring first.
- 4If the merchant account is on the same shard, credit it in the same transaction. Otherwise credit a per-shard 'in-transit' clearing account and emit an event; the saga worker credits the merchant on its shard idempotently.
- 5Response returns success and the new balance in under 200 ms; merchant gets a payment notification.
Withdraw to bank
- 1Full-KYC user requests withdrawal of ₹5,000; wallet debits user_main and credits 'payouts in flight' in one transaction.
- 2Payout worker calls IMPS via the bank with an idempotent reference; on success, it moves 'payouts in flight' to 'escrow cash out'.
- 3On a confirmed failure, a reversal journal credits the user back; unknown outcomes wait for status checks and bank reports, never a blind retry.
7 Deep dives
Ledger and balance correctness
A wallet balance is a liability we owe the user, so it must be exact and provable. Use double-entry: every movement is a journal whose lines sum to zero across accounts. Users, merchants, the escrow cash account, cashback expense, fee revenue and clearing accounts are all accounts. Keep a balance column on each account for fast reads, but update it only in the same database transaction that inserts the ledger lines, so the cached balance and the lines can never disagree. The debit is a conditional update: UPDATE accounts SET balance = balance - x, version = version + 1 WHERE id = ? AND balance >= x. If no row changes, the payment fails with insufficient balance; there is no read-then-write race. A CHECK constraint (balance >= 0) is a second guard. Idempotency comes from a unique key on the journal, so a retried request returns the original result. Money is integer paise. Corrections are reversing journals, never edits. Continuous integrity jobs check that every journal sums to zero, that each account's balance equals the sum of its lines, and that the total of all user and merchant balances equals the escrow bank balance plus in-flight amounts, the key regulatory invariant.
Cross-shard transfers
With 100M accounts, the ledger is sharded by account_id, so a P2P transfer or merchant payment often touches two shards. Distributed two-phase commit across shards works but adds latency and blocking during failures. The common alternative is a saga with clearing accounts. Step one, on the payer's shard: debit the payer and credit a clearing account 'outgoing to shard B', in one local transaction, and write an outbox event. Step two, a worker consumes the event and on shard B debits the 'incoming from shard A' clearing account and credits the payee, idempotent on the journal ID. The payer sees success after step one because the money has left their account safely; the payee sees it within milliseconds to seconds. If step two fails permanently (payee account blocked), a compensating journal on shard A reverses the transfer. Clearing accounts across shards must net to zero once all events are processed; a monitor alerts if they do not drain. Co-locating frequently paired accounts (a user and their own promo account) on the same shard avoids sagas where possible.
Hot merchant accounts
A large merchant can receive thousands of payments a second during a sale. If every payment updates one merchant balance row, that row becomes a lock hotspot and throughput collapses to a few hundred per second. Fixes: split the merchant account into N sub-accounts (e.g. 32) and credit a random one; the merchant balance is the sum. Or credit merchants asynchronously: the payment transaction debits the user and credits a per-shard 'merchant payable' clearing account, and a batch job aggregates hundreds of payments into one merchant credit every second. Merchants rarely need sub-second balance; they need reliable notifications and correct settlement. The user's side remains synchronous and strongly consistent, which is what matters for preventing double spend.
Promotional balance and cashback
Cashback is not the same as money the user loaded. It may be usable only at some merchants, it expires, and it must be accounted as a marketing expense. Store promotional balance as lots with amount, remaining, campaign and expiry. Crediting cashback posts: debit cashback expense (per campaign), credit user_promo. Spending consumes lots in order of earliest expiry, and the journal records which lots were used so refunds can restore them correctly. Expiry is a nightly job that posts reversing journals for expired remaining amounts. Campaign budgets are enforced with a counter per campaign, decremented atomically when cashback is granted, so a viral campaign cannot overspend. Fraud teams watch for patterns like many new wallets on one device claiming cashback.
8 Trade-offs
SQL ledger vs NoSQL
Balances need transactions, conditional updates and constraints. Sharded Postgres/MySQL (or distributed SQL) gives these directly. NoSQL stores scale writes but you rebuild transactional safety. Purpose-built ledgers like TigerBeetle give very high throughput for double-entry with strict invariants.
Two-phase commit vs saga for cross-shard transfers
2PC gives atomicity but blocks on coordinator failure and adds latency. A saga with clearing accounts is non-blocking and fast but the payee is credited a moment later and needs compensations. Wallets usually choose sagas.
Synchronous merchant credit vs batched
Synchronous credit is simple and shows the merchant balance immediately but creates hot rows. Batched credit scales and is enough for merchants who settle daily.
Balance column vs computing from lines
Summing lines on every read is too slow; keeping only a balance column loses auditability. Keep both, updated together in one transaction, and verify continuously.
9 Bottlenecks
Cashback campaign spike
A big campaign brings 20x traffic in minutes. Pre-scale, protect the ledger with admission control, and make cashback crediting asynchronous so it does not compete with payments.
Hot merchant row
Sub-accounts or batched merchant credits remove the lock hotspot.
Statement queries on huge ledgers
Old lines move to cold storage; statements use an index on (account_id, created_at) and a read replica, with monthly statements pre-generated.
10 Failure handling
Gateway webhook for a top-up is lost
The top-up stays pending. A reconciler queries the gateway for pending top-ups older than a few minutes and posts the credit idempotently; the daily settlement file is the final check.
Saga worker is down
Payers are already debited into clearing accounts; payees see credits delayed. When the worker resumes it processes the outbox backlog idempotently. Monitors alert on clearing account balances that do not drain.
Database primary of a shard fails
Failover to a synchronous replica (no committed transaction lost). Payments for users on that shard fail fast for seconds and clients retry with the same Idempotency-Key.
11 What impresses
- Explains the difference from UPI: a wallet issuer holds the money and owns the ledger.
- Writes the conditional update for debit and explains why it avoids races without locks in code.
- Uses clearing accounts and a saga for cross-shard transfers and shows they net to zero.
- Mentions the escrow-equals-liabilities invariant and daily checks.
- Handles hot merchants with sub-accounts or batched credits.
- Models cashback as expiring lots with campaign budgets.
12 Mistakes
- Reading the balance, checking in application code, then writing, allowing double spend.
- Only a balance column with no ledger.
- Crediting the wallet when the user returns from the payment page instead of on confirmed payment.
- Using floats for money.
- Ignoring KYC limits and regulatory rules.
- Using a distributed lock around every transfer.
13 Follow-ups
Lessons this design uses
Practice questions
- Your wallet is sharded by user_id. User A on shard 1 sends Rs 500 to user B on shard 7. How do you make the transfer correct without a distributed transaction?
- Design the database tables for a digital wallet where users add money, pay merchants and get refunds. Balances must never be wrong.
- Your wallet service stores each user's balance in a single 'balance' column and updates it on every transaction. Why would a payments team push back, and what would you build instead?
- A wallet has ₹1,000. Two requests to spend ₹700 arrive at the same millisecond on different servers. How do you make sure the wallet never goes negative?
- Which of these operations are safe to retry: 'set delivery address', 'add Rs 100 to wallet', 'add item to cart', 'cancel order'? How would you make the unsafe ones safe?
- A teammate says, 'We turned on Kafka exactly-once, so our consumer that updates user wallet balances can never apply a credit twice.' Is that right?
- In a wallet app, user A sends Rs 500 to user B. The wallets are in different database shards. The service crashes after debiting A and before crediting B. How do you design the transfer so money is never lost or created?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.