At a glance#
| Number | Value | What it drives |
|---|---|---|
| Payments a day | ~800 million | ~9,300 TPS average |
| Peak | ~28,000–46,000 TPS | 3–5× headroom for evenings and festivals |
| Backend ops at peak | ~170,000+ / s | each payment fans out: lookup, debit, credit, audit, 2 pushes |
| Users · banks | 550M+ · 740+ | one adapter and one circuit breaker per bank |
| End-to-end latency | < 2 s p99 | switch < 300 ms, each bank call < 700 ms |
| Storage | ~900 GB / day · ~2.3 PB / 7 yr | ~500 B per transaction plus audit logs |
| Availability | 99.9 %, no maintenance window | active-active data centres, RPO 0, RTO < 30 s |
Architecture#
DiagramUPI on one page: apps → NPCI switch → banks
%% title: UPI on one page: apps → NPCI switch → banks
flowchart TD
APP@{ icon: "sd:phone", label: "UPI apps · 550M+ users<br/>PhonePe · Google Pay · Paytm · BHIM" } --> PSP["PSP bank<br/>onboarding · VPA creation"]:::edge
PSP ==>|"signed, encrypted"| LB@{ icon: "sd:load-balancer", label: "Health-checked LB<br/>2 active-active DCs" }
LB --> TR["NPCI transaction router<br/>state machine · idempotency · fraud score"]:::hot
TR --> VPA["VPA mapper"]:::service
VPA --> VC@{ icon: "tech:redis", label: "Redis<br/>VPA + bank config" }
TR ==> GW["Bank gateway<br/>adapter + breaker per bank · mTLS"]:::service
GW ==>|"debit"| RB@{ icon: "sd:bank", label: "Remitter bank" }
GW ==>|"credit"| BB@{ icon: "sd:bank", label: "Beneficiary bank" }
TR --> EV@{ icon: "tech:kafka", label: "Kafka<br/>transaction events" }
EV --> AU@{ icon: "sd:log", label: "Audit trail" }
EV --> SET["Settlement engine<br/>net positions · cycles a day"]:::service
SET --> RBI@{ icon: "sd:building", label: "RBI settlement" }
class APP client
class LB edge
class VC cache
class EV queue
class AU,RBI store
class RB,BB external- Information flows app → PSP → NPCI → banks. Money moves bank to bank: accounts are posted in real time, interbank obligations are netted and settled through RBI.
- The switch is a stateless router plus a transaction log, so it scales out; banks stay the regulated money holders.
DiagramBank gateway: one adapter and one breaker per bank
%% title: Bank gateway: one adapter and one breaker per bank
flowchart TD
TR["Transaction router"]:::hot --> AD["Bank adapter layer<br/>each bank's quirks · timeouts · retries"]:::service
AD --> CB{"Per-bank circuit breaker"}
CB -->|"CLOSED · healthy"| OK@{ icon: "sd:bank", label: "Healthy bank" }
CB -->|"OPEN · skip 30 s, clear error"| DOWN@{ icon: "sd:bank", label: "Failing bank" }
CB -->|"HALF-OPEN · probe"| PROBE@{ icon: "sd:bank", label: "Recovering bank" }
AD -.-> SEC@{ icon: "sd:lock", label: "mTLS · request signing<br/>IP allow-list · leased lines" }
class OK ok
class DOWN bad
class PROBE queue
class SEC edgeKey flows#
DiagramPush payment: Alice pays Bob ₹500
%% title: Push payment: Alice pays Bob ₹500 sequenceDiagram autonumber participant A as Alice's app (PSP) participant N as NPCI switch participant H as Remitter bank (Alice) participant X as Beneficiary bank (Bob) A->>N: Pay ₹500 to bob@okaxis · txn ID + encrypted PIN Note over N: fraud score · idempotency check on txn ID N->>X: Resolve VPA bob@okaxis X-->>N: Bob's account N->>H: Debit ₹500 (verify PIN in bank HSM) H-->>N: Debited · ref ABC123 N->>X: Credit ₹500 X-->>N: Credited · ref XYZ789 N-->>A: Success · TXN123456 Note over A,X: under 2 s · audit row at every step · pushes to both sides
DiagramCredit times out: deemed success, then reconcile
%% title: Credit times out: deemed success, then reconcile
sequenceDiagram
autonumber
participant N as NPCI switch
participant H as Remitter bank
participant X as Beneficiary bank
N->>H: Debit (txn ID T1)
H-->>N: Debited
N->>X: Credit (T1)
X--xN: timeout
Note over N: never re-send blindly: mark T1 DEEMED_SUCCESS
N->>X: Status check (same T1, rate-limited)
N->>H: T1 in the settlement file
alt credit had landed
X-->>N: credited → COMPLETED
else credit never landed
H-->>N: auto-reverse debit by T+1 → REVERSED
endData model#
DiagramCore entities
%% title: Core entities
classDiagram
class Transaction {
+txnId : UUID · idempotency key
+payerVpa : string
+payeeVpa : string
+amount : paise
+state : TxnState
+remitterRef : string
+beneficiaryRef : string
+createdAt : timestamp
}
class VPA {
+address : string · bob@okaxis
+bankCode : string
+accountRef : token
+deviceBinding : hash
}
class Bank {
+code : string
+adapter : string
+breakerState : CLOSED / OPEN / HALF
+timeoutMs : int
}
class SettlementBatch {
+cycle : int
+netPositions : paise per bank
+status : string
}
class AuditEvent {
+txnId : UUID
+step : string
+at : timestamp
}
Transaction "many" --> "1" VPA : payer / payee
VPA "many" --> "1" Bank : held at
Transaction "1" --> "many" AuditEvent : logged as
SettlementBatch "1" --> "many" Transaction : nets- VPAs partition by bank suffix (
@okaxis), so a lookup is one routing hop; non-existent VPAs are negatively cached. - ~500 B per transaction row; the audit log is the bigger half of the ~900 GB a day.
Lifecycle#
DiagramTransaction state machine
%% title: Transaction state machine stateDiagram-v2 [*] --> CREATED CREATED --> PENDING: validation passed PENDING --> DEBIT_INITIATED: send to remitter bank DEBIT_INITIATED --> FAILED: debit refused DEBIT_INITIATED --> DEBITED: debit ok DEBITED --> CREDIT_INITIATED: send to beneficiary bank CREDIT_INITIATED --> COMPLETED: credit ok CREDIT_INITIATED --> REVERSED: credit refused CREDIT_INITIATED --> DEEMED_SUCCESS: credit timeout DEEMED_SUCCESS --> COMPLETED: reconciled, credit landed DEEMED_SUCCESS --> REVERSED: auto-reverse by T+1 COMPLETED --> [*] FAILED --> [*] REVERSED --> [*] class COMPLETED ok class FAILED,REVERSED bad class DEEMED_SUCCESS queue
Decisions#
| Decision | Chose | Over | Because |
|---|---|---|---|
| Who holds money | Banks; the switch only routes | A central wallet | no counterparty risk, banks stay regulated, switch scales out |
| Atomicity across two banks | State machine + one txn ID + reconciliation | Two-phase commit across banks | banks can't hold locks for you; a timeout must never become a double debit |
| Retries | Same txn ID on every retry and status check | New ID per attempt | idempotent at the switch and the banks |
| Bank isolation | Adapter + circuit breaker per bank | One shared client | one slow bank fails fast instead of draining the switch |
| Data centres | Active-active, synchronous replication | Active-passive | RPO 0, RTO < 30 s, no maintenance window |
| Money in flight | Real-time postings, net settlement in cycles | Gross settlement per payment | far fewer interbank transfers |
When it breaks#
| Failure | What users see | Contain it with |
|---|---|---|
| A big bank goes down (SBI ≈ a quarter of debits) | "Bank temporarily unavailable", fast | breaker opens, clear error, retry when it closes |
| Credit times out after debit | "Pending", money back by T+1 at the latest | deemed success, same-ID status checks, reconciliation |
| User taps Pay twice | one debit | txn ID created once on the device, checked at the switch |
| One NPCI data centre fails | a blip | active-active, health-checked routing, RPO 0 |
| Status-check flood (12 Apr 2025: ~50 % success for ~2 h) | payments fail nationwide | rate-limit every API, separate status checks from the payment path |
Say it in the interview#
- "NPCI moves information, banks move money." That one line explains the layers, the risk model and why the switch scales.
- Uncertainty is a state: debit ok + credit timeout →
DEEMED_SUCCESS, never a blind retry; reconcile, auto-reverse by T+1. - One ID end to end makes every retry and status check idempotent.
- Size for fan-out, not payments: ~46K TPS at peak is ~170K backend operations a second.
- Isolate per dependency: 740+ banks means 740+ adapters and breakers; one bank's outage stays that bank's.
- The 2025 outage lesson: rate-limit the cheap APIs too; status checks took the switch down, not payments.