Design a Payment Gateway (Razorpay / Paytm / PayU)
Solution and explanation
What it does
A payment gateway lets a merchant (an e-commerce site, an app) accept money by card, UPI, net banking or wallet. The merchant creates an order, the customer pays on the gateway's checkout, the gateway talks to banks and card networks, and the merchant is told the result and later receives the money (settlement).
Functional requirements
- Create order, show checkout, authorise and capture payments (card, UPI, net banking, wallets).
- Webhooks/callbacks to merchants with the final status.
- Refunds (full and partial), settlement to merchant bank accounts (T+1 / T+2), reconciliation with banks.
- Dashboard, reports, disputes/chargebacks.
Non-functional requirements
- Correctness above all: never charge twice, never lose a payment.
- High availability (99.99%+); every minute down is lost sales for thousands of merchants.
- Latency: checkout page < 300 ms; the bank leg is outside our control (2–30 s).
- Security and compliance: PCI-DSS for card data, RBI data-localisation, encryption at rest and in transit.
Scale estimate
- 10 crore payments/day ≈ 1,150/s on average, peaks of 10–20× during sales (≈ 20,000/s).
- Each payment ≈ 2 KB of core data plus events → ~200 GB/day before indexes.
High-level architecture
Merchant server ──▶ API Gateway (auth, rate limit) ──▶ Order Service ──▶ Payments DB
Customer ──▶ Checkout (web/SDK) ──▶ Payment Service ──▶ Router ──▶ Bank/Network adapters
│ (Visa/Mastercard/RuPay, NPCI UPI, net-banking)
├──▶ Ledger Service (double-entry)
├──▶ Event bus (Kafka) ──▶ Webhook Service ──▶ Merchant
├──▶ Risk/Fraud Service
└──▶ Tokenisation Vault (card numbers, PCI zone)
Settlement & Reconciliation jobs ◀── bank settlement files (daily)
Core data model
| Table | Key columns |
|---|---|
| orders | order_id, merchant_id, amount, currency, status (created/attempted/paid), receipt, created_at |
| payments | payment_id, order_id, method, amount, status (created → authorised → captured / failed / refunded), gateway_ref, bank_ref, idempotency_key |
| refunds | refund_id, payment_id, amount, status |
| ledger_entries | entry_id, txn_id, account, debit, credit, created_at (append-only) |
| webhook_deliveries | event_id, merchant_id, url, attempts, next_retry_at, status |
Payment flow (card)
- Merchant server calls
POST /orderswith amount and anIdempotency-Key. - Customer opens checkout; card number goes straight to the PCI vault and is replaced by a token.
- Payment Service creates a
payment(status created) and asks the Router which acquiring bank to use (success rate, cost, downtime). - Adapter sends authorisation to the card network → issuer bank → 3-D Secure OTP → authorised.
- Payment marked
authorised, thencaptured(immediately or when the merchant ships). - Ledger records double entries (customer receivable ↔ merchant payable, gateway fee).
payment.capturedevent → Kafka → Webhook Service delivers to the merchant with retries.- Next day: bank settlement file arrives; reconciliation matches every captured payment; money is settled to the merchant minus fees.
Key design decisions
- **Idempotency:** every create/capture/refund API takes an idempotency key stored with a unique index. A retried request returns the original result instead of charging again.
- **State machine:** payments move only through allowed transitions (created → authorised → captured → refunded). Updates use
UPDATE … WHERE status = 'authorised'(compare-and-set) so two workers cannot capture twice. - **Double-entry ledger:** money is never "edited"; each movement is two immutable rows that sum to zero. Balances are derived, which makes audits and reconciliation possible.
- **Unknown outcomes:** a bank timeout does not mean failure. Mark the payment
pending, poll the bank's status API and reconcile with the settlement file; only then mark failed and auto-refund if money was debited. - **Outbox pattern:** the DB update and the "payment captured" event are written in one transaction (outbox table), then a relay publishes to Kafka. No event is lost if the service crashes in between.
- **Webhooks:** at-least-once delivery with exponential backoff (1 min, 5 min, 30 min … up to 24 h), signed with HMAC so merchants can verify them; merchants must handle duplicates by event_id.
- **Smart routing:** track success rate per bank/card type/minute; route away from a degrading acquirer automatically.
Scaling and availability
- Shard payments by merchant_id or payment_id; read replicas for dashboards; OLAP store (ClickHouse/BigQuery) for reports.
- Multi-AZ deployment, active-passive across regions for the vault; circuit breakers around every bank adapter.
- Rate limits per merchant to protect shared bank connections.
Interview follow-ups
- *How do you prevent double charges when the user clicks Pay twice?* Idempotency key on order + one active attempt per order + compare-and-set state transitions.
- *What if the webhook endpoint is down?* Retries with backoff, a dashboard of failed deliveries, and a fetch-status API so merchants can poll.
- *Why not store card numbers in the main DB?* PCI scope: only the isolated vault sees PANs; everything else uses tokens.
