UPI processes more transactions than Visa and Mastercard do in India combined — north of 13 billion a month, peaking around 7,000 transactions per second, with an end-to-end latency budget measured in single-digit seconds and a correctness requirement of "never lose a rupee." It is, without exaggeration, one of the best real-world system design case studies available, and it shows up constantly in backend and platform interviews.
This post walks the whole thing: what happens between tapping "Pay" and the receiver's phone buzzing, what NPCI is really doing in the middle, the architecture you'd draw on a whiteboard, and a step-by-step interview script that starts from a naive design and evolves into something defensible.
1. What UPI actually is
UPI (Unified Payments Interface) is not a wallet and it does not hold your money. It is an interoperability layer and messaging standard operated by NPCI that lets any app talk to any bank account using a single addressing scheme — the VPA (Virtual Payment Address, e.g. upasana@okhdfc).
Three ideas make it work:
- Abstraction of the account. A VPA maps to an account number + IFSC inside the bank. You share the VPA; the account details never leave the bank.
- Push and pull in one rail. Both "send money" (push credit) and "request money" / "collect" (pull debit with payer authorization) run on the same message set.
- Authorization at the issuer, not the app. The UPI PIN is captured by a hardware-isolated NPCI Common Library inside the app, encrypted on the device, and can only be decrypted by the issuing bank's HSM. Google Pay never sees your PIN.
2. The players
| Entity | Role |
|---|---|
| Payer / Payee | End users, or a merchant on the payee side (P2M). |
| PSP app (TPAP) | The UI: GPay, PhonePe, Paytm, BHIM. Third-party apps are TPAPs and must ride on a sponsor bank's PSP handle. |
| PSP bank (sponsor bank) | The regulated bank that owns the UPI handle (@okaxis, @ybl, @paytm) and is the member that actually connects to NPCI. |
| Remitter / Issuer bank | Payer's bank. Owns the balance, performs the debit, and validates the UPI PIN in its HSM. |
| Beneficiary bank | Payee's bank. Performs the credit. |
| NPCI UPI switch | The central router, orchestrator, ledger of record for the interbank leg, and settlement engine. |
| Acquirer / merchant PSP | For P2M: onboards merchants, issues merchant VPAs and QR codes, does reconciliation and payouts. |
3. NPCI in depth
NPCI (National Payments Corporation of India) is an RBI-promoted, not-for-profit umbrella organisation for retail payments. It operates IMPS, RuPay, NACH, AePS, FASTag, BBPS and UPI. For UPI specifically it wears five different hats, and a strong answer separates them.
3.1 The switch (routing)
Every UPI message hits the NPCI switch. It resolves the VPA's handle suffix to the PSP bank, resolves the account to the issuer, and routes the ISO-8583-derived / XML-over-HTTPS API messages (ReqPay, RespPay, ReqAuthDetails, ReqChkTxn, ReqValAdd…) between four parties. It assigns the globally unique txnId (UPI transaction ID) that everyone reconciles against.
3.2 The orchestrator (2-phase money movement)
The interbank transfer is a distributed transaction across two independent banks that cannot hold a lock for each other. NPCI runs it as a saga: debit first, then credit, then compensate (auto-reversal) if the credit fails. NPCI keeps the state machine, the timers, and the retry/reversal obligations.
3.3 The clearing & settlement engine
Real-time to the user is not real-time between banks. During the day NPCI only moves messages; the actual money moves in net settlement cycles (UPI runs multiple cycles per day) through banks' settlement accounts at the RBI. NPCI nets each member's obligations — Bank A owes Bank B ₹412 crore — and posts a single settlement instruction. This is why a "successful" payment can still be reversed on T+0/T+1 after recon.
3.4 The rulebook & risk layer
- Circulars defining message formats, timeouts, and TAT (turnaround time) obligations.
- Limits: ₹1 lakh per transaction / 20 transactions per day by default, with higher caps for specific categories (capital markets, insurance, verified merchants).
- Mandatory auto-reversal within T+1 for failed credits, and penalty regimes for TAT breaches.
- Fraud analytics, velocity checks, blacklisted VPAs/devices, and the new-payee 24-hour cooling limit.
- Market-share cap policy for TPAPs (the 30% rule) — an interesting non-technical constraint that has real routing consequences.
3.5 The certification authority
NPCI certifies PSPs and banks, runs the UPI Common Library (the shared PIN-capture component), manages the PKI that binds device → app → issuer, and audits members.
4. The end-to-end payment flow
Here is the canonical P2P "scan and pay" path.
Payer app Payer PSP bank NPCI switch Remitter bank Beneficiary bank
| | | | |
1. scan QR / enter VPA | | | |
|---- ReqValAdd (validate payee VPA) --->| | |
|<--- RespValAdd (name, verified flag) --| | |
2. show payee name + amount, capture UPI PIN
(PIN encrypted inside NPCI Common Library, keyed to issuer HSM)
|---- ReqPay(txnId, payerVPA, payeeVPA, amt, encPIN, deviceFP) ------------------>|
| | | | |
3. | |--- ReqAuthDetails/Debit ------------->|
| | | (PIN verify in HSM, risk checks, |
| | | balance check, DEBIT + hold) |
| | |<-- Debit SUCCESS ---------------------|
4. | |------------- Credit request --------->|
| | |<------------ Credit SUCCESS ----------|
5. |<-- RespPay SUCCESS | | |
|<-- push notify ---| | | |
|--- notify payee PSP -----------------> payee app
6. End of day: NPCI nets obligations -> RBI settlement account movementStep by step, with the detail that matters
- Address resolution (ReqValAdd). Before any money is discussed, the payee VPA is validated and the registered name is returned so the payer can visually confirm. This is also the anti-phishing control for QR codes.
- PIN capture. The UPI PIN is entered in the NPCI Common Library, not the PSP's own UI. It is encrypted with the issuer's public key along with a device fingerprint, app ID, and a random salt so the ciphertext is never replayable.
- ReqPay. The PSP bank signs and forwards the request. NPCI mints/records
txnId, does its own risk scoring, and enters the transaction into its state machine asINITIATED. - Debit leg. The remitter bank decrypts the PIN in an HSM, runs its own fraud rules and limits, checks balance, and posts the debit inside a local ACID transaction along with an idempotency record keyed on
txnId. - Credit leg. Only after a confirmed debit does NPCI ask the beneficiary bank to credit. If the credit fails or times out, NPCI triggers an auto-reversal back to the remitter (must complete within T+1 by regulation).
- Responses & notifications. Both PSPs are notified; both apps push notifications. If the payer app never received the response, it polls
ReqChkTxn— which is why "payment status pending" resolves on its own. - Settlement. Netting + RBI settlement, then reconciliation files exchanged with every member. Discrepancies produce chargebacks/adjustments through the URCS dispute system.
5. Registration, device binding and the UPI PIN
- User installs the PSP app and enters a mobile number.
- The app sends an outbound SMS from the device to a shortcode. This proves the SIM physically lives in that handset — this is the device-binding step, and it is why UPI registration fails on dual-SIM/eSIM edge cases and on tablets.
- NPCI issues a device token bound to (mobile, device fingerprint, app).
- The app queries banks by mobile number to discover linked accounts (masked), and the user picks one; a VPA is created and mapped.
- UPI PIN is set using debit card last 6 digits + expiry (or Aadhaar OTP). The PIN is stored only as a verifier inside the issuer's HSM.
6. System design: high-level architecture
If you are asked to "design UPI," you are usually being asked to design the NPCI switch plus a PSP. Here's the target picture.
┌───────────────┐
Mobile apps ──▶│ API Gateway │ mTLS, signature verify, rate limit, WAF
(PSP/TPAP) └──────┬────────┘
│
┌───────────────┼────────────────────────────┐
▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌───────────────┐
│ VPA / Addr │ │ Payment │ │ Mandate / │
│ Registry │ │ Orchestrator│◀── Kafka ──▶│ Autopay Svc │
└─────┬──────┘ └──────┬──────┘ └───────────────┘
│ │ (saga state machine, idempotent)
│ ┌─────────┼──────────┬─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│Routing │ │Risk /│ │ Ledger │ │ Bank │ │Notifica- │ │ Recon & │
│ Svc │ │Fraud │ │ Service│ │ Connector │ │tion Svc │ │Settlement│
└────────┘ └──────┘ └───┬────┘ │ (per-bank │ └──────────┘ └────┬─────┘
│ │ circuit │ │
┌────────▼───┐ │ breaker) │ ┌──────▼──────┐
│ Txn store │ └─────┬─────┘ │ Data lake / │
│ (sharded │ │ │ files, RBI │
│ Postgres │ Issuer & Beneficiary │ settlement │
│ by txnId) │ banks └─────────────┘
└────────────┘Service responsibilities
- API gateway / edge: mTLS with member banks, XML/JSON signature verification, replay protection (nonce + timestamp), per-member quotas, and shedding.
- VPA registry: read-heavy, cache-friendly (Redis, ~99% hit rate), maps handle → PSP bank, VPA → account token. Strongly consistent writes, eventually consistent reads are acceptable except during creation.
- Payment orchestrator: the heart. A durable saga/state machine per transaction with persisted state transitions, timers, and compensations. Stateless pods + state in the DB, so any pod can resume any transaction.
- Bank connector: one logical adapter per member bank with its own bulkhead, circuit breaker, connection pool and timeout profile. A slow bank must never exhaust global threads — this is the classic UPI outage cause.
- Ledger service: append-only, double-entry, immutable. Never
UPDATE balance SET…; write entries and derive balances with snapshots. - Risk engine: synchronous cheap rules (velocity, limits, blacklists) in the hot path; expensive ML scoring async or on a shadow path.
- Recon & settlement: batch pipeline that ingests bank files, matches on
txnId+ RRN, raises exceptions, and computes net positions.
Data model choices
- Transactions: sharded relational store (Postgres/Vitess/Spanner-like) sharded by
txnIdhash. Each transaction's whole lifecycle lives in one shard, so no cross-shard transaction is ever needed. - Ledger entries: append-only, partitioned by date, archived to object storage after 90 days; queries served from a columnar warehouse.
- Events: Kafka with
txnIdas the partition key → per-txn ordering for free. - Cache: Redis for VPA resolution, idempotency keys, and rate-limit counters.
7. Capacity estimation
| Metric | Estimate | How you get there |
|---|---|---|
| Monthly transactions | ~14 B | Published NPCI volumes |
| Daily average | ~465 M | 14 B / 30 |
| Average TPS | ~5.4 K | 465 M / 86,400 |
| Peak TPS | ~15–20 K | 3–4× average (festival/evening peaks) |
| Messages per transaction | 6–8 | ValAdd, ReqPay, debit, credit, resp, notify, status |
| Peak message rate | ~120 K msg/s | 20 K × 6 |
| Row size | ~1 KB with metadata | IDs, amounts, status, timestamps, signatures |
| Hot storage / day | ~500 GB | 465 M × 1 KB |
| Hot storage / year | ~180 TB (before replication) | 500 GB × 365 |
| Latency budget | p99 < 3 s end-to-end | NPCI hop ~200 ms; banks dominate |
8. How to design UPI in an interview
The trick is to evolve the design. Start deliberately naive, then break your own design and fix it. Here's a script that fits a 45-minute loop.
Minute 0–5: scope and requirements
Say out loud what you're building and what you're not.
- In scope: P2P and P2M push payments, VPA management, status/recon, reversals.
- Out of scope (call it, then park it): mandates/autopay, UPI Lite, credit-line on UPI, international UPI, dispute UI.
- Functional: register + bind device, create VPA, resolve VPA, pay, collect request, check status, history, notifications, reversal.
- Non-functional: 20K peak TPS; p99 < 3 s; 99.99% availability; no money loss ever (durability > availability on the ledger); auditable for 10 years; PCI-DSS and RBI compliance; idempotency across all APIs.
Minute 5–10: Design v1 — the naive monolith
[App] → [Payment Service] → [Postgres: accounts table]
BEGIN;
UPDATE accounts SET bal = bal - 500 WHERE id = payer;
UPDATE accounts SET bal = bal + 500 WHERE id = payee;
COMMIT;Then immediately attack it: this only works when both accounts live in one database. In UPI they live in different banks. There is no shared transaction. Also: single point of failure, no idempotency, mutable balances (no audit trail), and hot-row contention on popular merchants.
Minute 10–18: v2 — introduce the switch and the saga
- Add NPCI as a central switch with a per-transaction state machine.
- States:
INITIATED → DEBIT_PENDING → DEBITED → CREDIT_PENDING → SUCCESS, withDEBIT_FAILED,CREDIT_FAILED → REVERSAL_PENDING → REVERSED,DEFERRED/TIMEOUT. - Persist every transition before making the downstream call, so a crash can be resumed from the log.
- Make every external API idempotent on
txnId; the bank stores the result of the first call and replays it.
Minute 18–24: v3 — correctness under failure
- Timeouts are not failures. A debit timeout is "unknown". You must run a status-check (
ReqChkTxn) loop before deciding to reverse — otherwise you double-debit or wrongly refund. - Reconciler as a background sweeper. A job scans transactions stuck in non-terminal states past their SLA and drives them to terminal states. This is your safety net and interviewers explicitly look for it.
- Double-entry immutable ledger instead of mutable balances, with a daily trial-balance job asserting debits == credits.
- Exactly-once effect, at-least-once delivery. Retries + idempotency keys, never "hope the network is fine."
Minute 24–32: v4 — scale and isolation
- Shard the transaction store by
txnId; keep one transaction on one shard. - Cache VPA resolution; it's 30–40% of all traffic and is read-mostly.
- Per-bank bulkheads + circuit breakers. When SBI is slow, only the SBI pool degrades; everything else keeps flowing. Fail fast with a "bank unavailable" response rather than queueing.
- Backpressure and load shedding at the gateway with priority tiers (P2M checkout > balance enquiry).
- Hot merchant accounts: don't lock a single row per credit — write ledger entries and aggregate, or use per-merchant sub-accounts / batched credits.
- Multi-region active-active with synchronous replication for the ledger quorum.
Minute 32–38: v5 — security, risk, and settlement
- PIN never in application memory in plaintext; HSM-backed decryption at the issuer only.
- mTLS + per-message digital signatures between all members; nonce-based replay protection.
- Device binding, app attestation, and SIM-change detection.
- Real-time rules (velocity, new-payee cap, amount limits) synchronous; ML scoring async.
- Netting + settlement cycles into RBI accounts; recon files and a dispute/chargeback flow.
- Tokenized PII, field-level encryption, and immutable audit logs.
Minute 38–45: the final picture
Redraw the architecture from section 6 and narrate the request path once, end to end, calling out where each failure mode is handled. Then invite the interviewer into the part you find most interesting — usually reversals or hot-merchant contention.
9. Low level design
The LLD round usually zooms into the payment orchestrator. Model it as a state machine with pluggable strategies, not as a pile of if-statements.
Core entities
enum TxnState {
INITIATED, RISK_APPROVED, DEBIT_PENDING, DEBITED,
CREDIT_PENDING, SUCCESS,
DEBIT_FAILED, CREDIT_FAILED,
REVERSAL_PENDING, REVERSED, DEFERRED
}
class Transaction {
TxnId txnId; // globally unique, idempotency key
String rrn; // 12-digit retrieval reference number
Vpa payer, payee;
Money amount; // long minorUnits + Currency, NEVER double
TxnType type; // P2P_PUSH, P2M_PUSH, COLLECT, MANDATE, REFUND
TxnState state;
int version; // optimistic locking
Instant createdAt, updatedAt, expiresAt;
Map<String,String> meta; // deviceFp, appId, merchantId, mcc
}
class LedgerEntry { // append-only, never updated
UUID entryId;
TxnId txnId;
String accountId;
Money amount;
Side side; // DEBIT | CREDIT
Instant postedAt;
}The state machine
interface TransitionHandler {
TxnState handle(Transaction txn, Event event);
}
class StateMachine {
Map<Pair<TxnState, EventType>, TransitionHandler> table;
void apply(Transaction txn, Event e) {
var handler = table.get(Pair.of(txn.state, e.type));
if (handler == null) throw new IllegalTransition(txn.state, e.type);
var next = handler.handle(txn, e);
txnRepo.compareAndSwapState(txn.txnId, txn.state, next, txn.version); // optimistic
outbox.publish(new StateChanged(txn.txnId, next)); // same DB txn
}
}The transactional outbox is the detail that makes this correct: the state change and the event that announces it are written in one local DB transaction, and a relay publishes to Kafka afterwards. Without it you get "state changed but nobody was told" or "told twice, acted twice."
Idempotency
class IdempotencyGuard {
Optional<Response> begin(String key) {
// INSERT ... ON CONFLICT DO NOTHING returns 0 rows if already seen
if (!store.tryInsert(key, IN_PROGRESS, ttl)) {
var rec = store.get(key);
if (rec.status == IN_PROGRESS) throw new ConcurrentRequest(); // 409, client retries
return Optional.of(rec.response); // replay stored result
}
return Optional.empty();
}
void complete(String key, Response r) { store.put(key, DONE, r); }
}Patterns worth naming
| Pattern | Where it's used |
|---|---|
| State / State machine | Transaction lifecycle |
| Saga + compensating transaction | Debit → credit → auto-reversal |
| Strategy | P2P vs P2M vs collect vs mandate execution rules |
| Chain of responsibility | Risk rule pipeline (limits → velocity → blacklist → ML) |
| Adapter + Bulkhead | Per-bank connectors with isolated pools |
| Transactional outbox | Reliable event publication |
| Circuit breaker + Retry with jitter | Bank calls |
| Observer | Notifications, analytics, audit fan-out |
| Factory | Building bank-specific request payloads |
Key APIs
POST /v1/vpa { handle } -> 201 { vpa }
GET /v1/vpa/{vpa}/resolve -> 200 { name, verified }
POST /v1/payments Idempotency-Key: <txnId>
{ payerVpa, payeeVpa, amountMinor, currency, encPin, deviceFp, remarks }
-> 202 { txnId, state }
GET /v1/payments/{txnId} -> 200 { state, rrn, ts }
POST /v1/payments/{txnId}/reverse { reason } -> 202 { reversalId }
POST /v1/collect { payeeVpa, payerVpa, amountMinor, expiryMins }Note the 202: payment initiation is asynchronous by nature. The client polls or listens on a socket/push channel. Returning 200 SUCCESS synchronously is a design smell in a system whose downstream is two independent banks.
Concurrency rules
- Optimistic locking (version column) on the transaction row; never a long-held pessimistic lock.
- Money as integer minor units. A
doublein a payment system is an automatic fail. - Balance derived from the ledger with periodic snapshots; hot accounts get sharded sub-ledgers.
- All timers (expiry, reversal SLA) persisted in a durable scheduler, not in-process.
10. Edge cases and failure modes
| Scenario | Correct handling |
|---|---|
| Debit succeeded, credit failed | Auto-reversal to payer within T+1; reversal is itself a transaction with its own idempotency and retries. |
| Debit response timed out (unknown) | Never assume failure. Poll ReqChkTxn with backoff; only reverse after the bank confirms no debit, or after the recon file settles it. |
| User taps Pay twice | Same idempotency key from the client → second call replays the first result. Client-side debounce is a UX nicety, not a correctness control. |
| Money debited, app shows failed | State is pending, not failed. Sweeper resolves it; user sees 'pending' with an auto-refund SLA — the single biggest real-world UPI support issue. |
| Beneficiary bank down | Fail fast via circuit breaker; do not debit at all if the target bank is known-down (pre-flight health). |
| Insufficient balance | Clean terminal failure at the debit leg; no reversal needed. |
| Wrong VPA / correct VPA, wrong person | Not a system failure — this is why ReqValAdd shows the registered name before PIN entry. Dispute flow, not reversal. |
| Duplicate credit in recon | Detected by trial balance and RRN matching; adjustment entry posted, never a silent UPDATE. |
| Replay attack | Nonce + timestamp window + signature; PIN ciphertext is salted per request. |
| Peak-hour overload | Priority-based load shedding; queue non-critical (history, notifications) and protect the payment path. |
| Clock skew between members | All state timestamps server-assigned at NPCI; use monotonic sequence for ordering, not wall clock. |
| Partial network partition | Ledger quorum writes; if quorum is lost, refuse new debits (choose consistency over availability for money). |
11. Follow-up questions to expect
- How do you guarantee exactly-once money movement over an at-least-once network? Idempotency keys + terminal-state persistence + reconciliation. Exactly-once delivery is impossible; exactly-once effect is what you build.
- Why not 2PC? Cross-organisational locks, unbounded blocking on coordinator failure, and no bank will grant another bank a prepare-lock. Saga + compensation with a regulated reversal SLA.
- How does settlement differ from the transaction? Transactions are messages in real time; settlement is netted movement between bank accounts at RBI in discrete cycles.
- How would you add UPI Autopay (mandates)? A mandate object with amount cap, frequency, validity, and pre-debit notification 24h ahead; execution is a scheduled payment referencing the mandate ID with its own idempotency.
- How would you support UPI Lite? An on-device prepaid balance that avoids the issuer round-trip for small-value payments; batched settlement to the bank. Trade-off: lower latency and less load, at the cost of on-device state and a top-up flow.
- How do you shard when a merchant does 50K credits/minute? Sub-accounts / entry-level sharding + async aggregation; never one row per merchant balance.
- How do you test this? Deterministic simulation of bank failures, chaos on connectors, property-based tests asserting ledger invariants, and a shadow-traffic environment.
- What's your observability story? Per-bank success-rate SLOs (NPCI publishes these), state-transition dashboards, stuck-transaction alarms, and trial-balance alerts.
- How would you extend to cross-border? FX quote service, sanctions/AML screening in the hot path, and settlement through a correspondent bank with different cut-off times.
Practice this, don't just read it
Reading a UPI breakdown feels productive; drawing it under time pressure is a different skill. Try it on Archtin: sketch the switch, defend the saga, and get the reversal path picked apart before an interviewer does it for you.