At a glance#
| Number | Value | What it drives |
|---|---|---|
| Volume | $180B+ a year · 1B+ a quarter | ~127 payments/s on average |
| Design peak | 10,000 TPS | headroom over ~1,300 TPS busiest minutes (assumed 10×) |
| Work per payment | ~15 writes / events | ~150,000 internal operations/s at the peak |
| Webhooks | ~30,000 / s at the peak | at least once, retried 24 h, 5-s answer |
| Unknown outcomes | ~13,500 at once at the peak | UNKNOWN is a state with its own timers |
| UPI status checks | 3, 90 s apart | settlement files are the backstop |
| Settlement | T+2 working days from escrow | a balanced ledger per merchant |
| Uncaptured authorisations | refunded after 3 days | AUTHORIZED is never forever |
Architecture#
DiagramPayment gateway on one page
%% title: Payment gateway on one page
flowchart TD
MS@{ icon: "sd:server", label: "Merchant server<br/>orders · capture · refunds" } ==> GW@{ icon: "sd:gateway", label: "API gateway<br/>idempotency keys · per-merchant limits" }
CK@{ icon: "sd:phone", label: "Checkout<br/>web / app SDK" } ==> GW
CK -.->|"card details"| VAULT@{ icon: "sd:lock", label: "Card vault<br/>PCI-DSS · tokens" }
GW ==> PAY["Payment service<br/>state machine · one row per payment"]:::hot
PAY ==> RT["Router<br/>success rate per method · issuer · processor"]:::core
PAY ==> LED@{ icon: "sd:database", label: "Ledger<br/>double-entry · append-only" }
PAY -.->|"outbox"| BUS@{ icon: "tech:kafka", label: "Kafka<br/>payment events" }
RT ==> ADP["Processor adapters<br/>acquirers · UPI switch · net banking"]:::service
ADP ==> BANKS@{ icon: "sd:bank", label: "Banks · networks · NPCI" }
BUS --> WH@{ icon: "sd:webhook", label: "Webhooks<br/>queue per merchant · 24-h retries" }
BUS --> REC["Reconciler<br/>status checks · settlement files"]:::core
LED --> SET["Settlement<br/>T+2 · or instant"]:::service
SET --> ESC@{ icon: "sd:bank", label: "Escrow bank account" }
class MS,CK client
class GW,VAULT edge
class LED store
class BUS queue
class WH queue
class BANKS,ESC external- The payment service is the only writer of a payment's state; callbacks, status checks and merchant calls are transition requests it accepts or refuses.
- State change, ledger entries and the event commit together (outbox); webhooks and reconciliation consume the events.
- The card vault is separate and minimal: nothing else ever sees a card number.
DiagramRouting one payment
%% title: Routing one payment
flowchart TD
P["Payment<br/>card · Visa · issuer X"]:::core --> ALLOW["Merchant's allowed processors<br/>and priorities"]:::service
SR@{ icon: "sd:chart", label: "Success rate per slice<br/>last 5 min vs baseline" } --> DOWN
ALLOW --> DOWN{"Route down<br/>for this slice?"}
DOWN -->|"no"| PICK["Best score<br/>rate · priority"]:::hot
DOWN -->|"yes · 2% probe"| PROBE["Probe the down route"]:::service
PICK ==> ADP["Processor adapter"]:::service
PROBE --> ADP
ADP -.->|"technical result"| SR
class SR cacheKey flows#
DiagramOne card payment, order to webhook
%% title: One card payment, order to webhook sequenceDiagram autonumber participant M as Merchant participant G as Gateway participant V as Vault participant R as Router participant B as Acquirer + issuer participant W as Webhooks M->>G: Create order ₹1,999 (idem key) G-->>M: order_A1 G->>V: Card details from checkout V-->>G: token G->>R: Route · issuer X R-->>G: Acquirer P2 G->>B: Authorise token ₹1,999 B-->>G: OTP, then authorised Note over G: CREATED → AUTHORIZED → CAPTURED · ledger entries in the same commit G-)W: payment.captured (event id) W->>M: Signed POST M-->>W: 200 within 5 s
DiagramThe UPI result never came
%% title: The UPI result never came
sequenceDiagram
autonumber
participant G as Payment service
participant S as UPI processor
participant R as Reconciler
participant F as Settlement file
G->>S: UPI request (ref T1)
Note over G: 30 s, no callback · UNKNOWN · customer told "confirming, don't pay again"
R->>S: Status check at 90 s (T1)
S-->>R: Pending
R->>S: Status check at 180 s (T1)
alt definite answer
S-->>R: Success
R->>G: UNKNOWN → AUTHORIZED → capture
else still pending after 270 s
R->>F: Next day · is T1 in the file?
F-->>R: Present
R->>G: Success · or late success if already failed
endData model#
DiagramCore entities
%% title: Core entities
classDiagram
class Order {
+orderId : string
+merchantId : string
+amount : paise
+status : open / paid / closed
}
class Payment {
+paymentId : string
+orderId : string
+method : card / upi / netbanking / wallet
+state : PaymentState
+processor : string
+processorRef : string · same on every check
}
class IdemKey {
+merchantId : string
+key : string
+requestHash : sha256
+response : stored json
}
class Posting {
+postingId : string · capture:pay_B1
+kind : capture / refund / settlement
}
class Entry {
+account : string · merchant_payable:m_42
+amountPaise : int · entries sum to zero
}
class WebhookEvent {
+eventId : string · stable across retries
+merchantId : string
+attempt : int
}
Order "1" --> "many" Payment : attempts
Payment "1" --> "many" Posting : posts
Posting "1" --> "many" Entry : balanced
Payment "1" --> "many" WebhookEvent : notifies- Amounts are integer paise; every posting sums to zero and is never edited.
- Payments ~2 KB, ledger ~9 GB a day; with a 10-year retention assumption, ~113 TB.
Lifecycle#
DiagramPayment state machine
%% title: Payment state machine stateDiagram-v2 [*] --> CREATED CREATED --> PENDING: sent to processor PENDING --> AUTHORIZED: bank approved PENDING --> FAILED: declined PENDING --> UNKNOWN: no answer in time UNKNOWN --> AUTHORIZED: check or callback says yes UNKNOWN --> FAILED: says no, or not in the file AUTHORIZED --> CAPTURED: merchant or auto-capture AUTHORIZED --> AUTO_REFUNDED: not captured in 3 days FAILED --> LATE_SUCCESS: money moved after all LATE_SUCCESS --> CAPTURED: order still open LATE_SUCCESS --> AUTO_REFUNDED: order closed CAPTURED --> REFUNDED: merchant refunds CAPTURED --> [*] REFUNDED --> [*] AUTO_REFUNDED --> [*] class CAPTURED ok class FAILED,REFUNDED,AUTO_REFUNDED bad class UNKNOWN,LATE_SUCCESS queue
Decisions#
| Decision | Chose | Over | Because |
|---|---|---|---|
| Atomicity with banks | Guarded state machine + reconciliation | Distributed transaction | a bank can't hold our locks |
| Idempotency | Key + request hash, stored with the effect | Cache entry with a TTL | a crash can't leave the effect unrecorded |
| Timeouts | UNKNOWN state | Mark failed | a premature "failed" invites a second charge |
| Routing | Success rate per method · issuer · processor | One rate per processor | failures are local to a slice |
| Money records | Double-entry, append-only ledger | Balance column | every rupee traceable, balances rebuildable |
| Merchant notification | At least once, event ids, queue per merchant | Exactly-once delivery | impossible over the internet; dedupe instead |
When it breaks#
| Failure | What users see | Contain it with |
|---|---|---|
| Bank never answers | "confirming, don't pay again" | UNKNOWN, 3 checks 90 s apart, settlement file |
| Money arrives after "failed" | capture if open, else auto refund | LATE_SUCCESS path |
| One acquirer fails for one issuer | a retry that works | route down for that slice, 2% probes |
| One issuer itself is slow | a warning before paying | can't route around it; suggest another method |
| A merchant's server returns 500s | its webhooks arrive late | per-merchant queue, backoff, disable after 24 h |
| A sale at 20× normal | other merchants unaffected | per-merchant limits and pools |
Say it in the interview#
- "Never turn silence into an answer." A timeout is UNKNOWN, resolved by checks and files.
- Exactly once lives in two places: the idempotency record and the conditional state transition.
- Everything across a network is at least once: callbacks, checks and webhooks are made safe by ids and state.
- Size for fan-out: 10,000 TPS is ~150,000 internal operations a second.
- Route per slice: one issuer through one acquirer fails, not the whole processor.
- Prove the money: ledger, escrow statement and processors' files must match every day.