~learn.

Razorpay Payment Gateway, the whole design.

A gateway that takes over a billion payments a quarter for online businesses across cards, UPI and banks, and gives every one exactly one final outcome by treating silence as UNKNOWN and reconciling it, never guessing.

6 diagrams · 8 sections · revise in ~10 min · Download PDF prints it in the light theme, diagrams included

SQ-18 · Razorpay Payment Gateway · design sheethimanshukukreja.in/learn/sheets/bonus-18-razorpay-payment-gateway

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
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 cache

Key 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
  end

Data 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

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#