How to Design Digital Payments Platform like PhonePe
A Senior+ Guide to Building UPI Payments, Wallet Systems, and Merchant Settlements at 500M+ Transaction Scale
1. Introduction — The UPI Revolution
India's Unified Payments Interface (UPI) has fundamentally transformed how 500 million+ users transfer money, pay merchants, and manage finances. PhonePe, along with Google Pay and Paytm, processes over 10 billion monthly transactions worth more than ₹20 lakh crore — making UPI the world's largest real-time payment system by volume.
Building a platform that handles this scale requires meticulous engineering across distributed systems, real-time processing, strong consistency, fraud detection, and regulatory compliance. This guide walks you through every aspect of designing such a system — from UPI protocol internals to database sharding strategies, from idempotent transaction processing to multi-region disaster recovery.
Whether you're preparing for a senior/staff-level system design interview or architecting a production payment system, this article covers every critical detail you need to know.
2. Requirements & Scope
Functional Requirements
- User Registration & KYC: Phone number, Aadhaar-based eKYC, PAN verification
- Bank Account Linking: Add multiple bank accounts, set primary account
- UPI Payments: P2P transfers, P2M payments via VPA or QR code
- Wallet: Load money from bank, pay from wallet, wallet-to-wallet transfers
- QR Code Payments: Static/dynamic QR generation, merchant payments
- Bill Payments & Recharges: Electricity, water, DTH, mobile recharge
- Merchant Settlement: T+1 settlement cycle, split payments, refunds
- Transaction History: Filtered, searchable, exportable transaction logs
- Notifications: Real-time SMS, push, and email notifications
- Fraud Detection: Real-time risk scoring, velocity checks, device fingerprinting
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.99% (52 min downtime/year) | Financial services demand near-zero downtime |
| Latency (P99) | < 2 seconds end-to-end | Users expect instant payment confirmation |
| Throughput | 50,000+ TPS peak | Festival seasons see 3-5x normal traffic |
| Durability | Zero transaction loss | Every transaction must be recoverable |
| Consistency | Strong consistency for balances | Double-spend prevention is non-negotiable |
| Security | PCI-DSS Level 1 compliance | Mandatory for payment card processing |
3. Capacity Estimation
Traffic Estimation
Daily Active Users (DAU): 100 million
Average transactions per user per day: 3
Total daily transactions: 300 million
Peak TPS (2x average): ~7,000 TPS normal, ~25,000 TPS during festivals
Read-heavy ratio: 80% reads (balance checks, history) → 120,000 reads/sec
Storage Estimation
| Data Type | Record Size | Daily Volume | Daily Storage |
|---|---|---|---|
| Transactions | 500 bytes | 300M | 150 GB |
| User Profiles | 2 KB | 100K new | 200 MB |
| Session/OTP Logs | 100 bytes | 500M | 50 GB |
| Notification Logs | 200 bytes | 600M | 120 GB |
| Total Daily | ~320 GB |
Bandwidth Estimation
Average request payload: ~1 KB. Average response: ~2 KB. Total bandwidth: 300M × 3KB = ~900 GB/day ≈ 10 MB/s average. Peak: ~40 MB/s. CDN and edge caching reduce origin load by 60-70%.
4. Data Model Design
Core Entities
-- Users Table (Primary shard key: user_id)
CREATE TABLE users (
user_id BIGINT PRIMARY KEY,
phone_number VARCHAR(15) UNIQUE NOT NULL,
full_name VARCHAR(200) NOT NULL,
email VARCHAR(200),
avatar_url VARCHAR(500),
kyc_status ENUM('NONE','MINIMAL','FULL','REJECTED') DEFAULT 'NONE',
kyc_verified_at TIMESTAMP NULL,
risk_score DECIMAL(5,2) DEFAULT 0.00,
status ENUM('ACTIVE','SUSPENDED','BLOCKED') DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Bank Accounts (Linked via NPCI registry)
CREATE TABLE bank_accounts (
account_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
bank_code VARCHAR(20) NOT NULL,
account_number VARCHAR(50) NOT NULL, -- Encrypted at rest
account_type ENUM('SAVINGS','CURRENT','CREDIT') DEFAULT 'SAVINGS',
ifsc_code VARCHAR(11) NOT NULL,
is_primary BOOLEAN DEFAULT FALSE,
is_verified BOOLEAN DEFAULT FALSE,
upi_pin_set BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_bank (user_id)
);
-- UPI VPA Handles
CREATE TABLE upi_handles (
handle_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
vpa_address VARCHAR(100) UNIQUE NOT NULL,
handle_provider VARCHAR(20) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_vpa (vpa_address),
INDEX idx_user_vpa (user_id)
);
-- Transactions (Sharded by sender_id with date partitioning)
CREATE TABLE transactions (
transaction_id VARCHAR(36) PRIMARY KEY,
idempotency_key VARCHAR(64) UNIQUE NOT NULL,
sender_id BIGINT NOT NULL,
receiver_id BIGINT NULL,
sender_vpa VARCHAR(100),
receiver_vpa VARCHAR(100),
sender_bank VARCHAR(20),
receiver_bank VARCHAR(20),
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'INR',
txn_type ENUM('P2P','P2M','WALLET_LOAD','WALLET_PAY',
'BILL_PAY','REFUND','CASHBACK') NOT NULL,
status ENUM('INITIATED','PENDING','COMPLETED','FAILED',
'REVERSED','TIMEOUT') DEFAULT 'INITIATED',
upi_rrn VARCHAR(20),
npc_txnid VARCHAR(50),
bank_ref VARCHAR(50),
description VARCHAR(500),
failure_reason VARCHAR(500),
risk_score DECIMAL(5,2) DEFAULT 0.00,
metadata_json JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
INDEX idx_sender_date (sender_id, created_at),
INDEX idx_receiver_date (receiver_id, created_at),
INDEX idx_status (status),
INDEX idx_idempotency (idempotency_key)
) PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
PARTITION p2026_01 VALUES LESS THAN (UNIX_TIMESTAMP('2026-02-01')),
PARTITION p2026_02 VALUES LESS THAN (UNIX_TIMESTAMP('2026-03-01')),
PARTITION p2026_03 VALUES LESS THAN (UNIX_TIMESTAMP('2026-04-01'))
);
-- Wallets
CREATE TABLE wallets (
wallet_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNIQUE NOT NULL,
balance DECIMAL(15,2) DEFAULT 0.00,
locked_amount DECIMAL(15,2) DEFAULT 0.00,
version BIGINT DEFAULT 0,
status ENUM('ACTIVE','FROZEN','CLOSED') DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Merchants
CREATE TABLE merchants (
merchant_id BIGINT PRIMARY KEY AUTO_INCREMENT,
business_name VARCHAR(300) NOT NULL,
owner_user_id BIGINT NOT NULL,
gst_number VARCHAR(20),
pan_number VARCHAR(20),
mcc_code VARCHAR(10),
settlement_cycle VARCHAR(10) DEFAULT 'T1',
commission_rate DECIMAL(5,4) DEFAULT 0.0000,
status ENUM('ACTIVE','SUSPENDED','PENDING_APPROVAL') DEFAULT 'PENDING_APPROVAL',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_owner (owner_user_id)
);
-- Merchant Settlements
CREATE TABLE settlements (
settlement_id BIGINT PRIMARY KEY AUTO_INCREMENT,
merchant_id BIGINT NOT NULL,
settlement_date DATE NOT NULL,
total_txn_count INT DEFAULT 0,
total_amount DECIMAL(15,2) DEFAULT 0.00,
commission DECIMAL(15,2) DEFAULT 0.00,
net_amount DECIMAL(15,2) DEFAULT 0.00,
status ENUM('PENDING','PROCESSING','COMPLETED','FAILED') DEFAULT 'PENDING',
bank_ref VARCHAR(50),
processed_at TIMESTAMP NULL,
INDEX idx_merchant_date (merchant_id, settlement_date)
);
Entity Relationship Overview
| Entity | Key Relationships | Sharding Strategy |
|---|---|---|
| users | 1:N bank_accounts, 1:N upi_handles, 1:1 wallets | Hash(user_id) → 256 shards |
| transactions | N:1 users (sender/receiver), M:1 merchants | Hash(sender_id) + date partition |
| merchants | 1:N settlements, 1:N transactions | Hash(merchant_id) → 64 shards |
| wallets | 1:1 users, 1:N wallet_transactions | Same shard as user |
5. API Design
REST API Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/auth/send-otp | Send OTP for login | None |
| POST | /api/v1/auth/verify-otp | Verify OTP, return JWT | None |
| POST | /api/v1/users/register | Create user profile | JWT |
| GET | /api/v1/users/me | Get current user profile | JWT |
| POST | /api/v1/bank-accounts/link | Link bank account via UPI | JWT + PIN |
| GET | /api/v1/bank-accounts | List linked accounts | JWT |
| POST | /api/v1/upi/create-vpa | Create UPI handle | JWT |
| GET | /api/v1/wallet/balance | Check wallet balance | JWT |
| POST | /api/v1/wallet/load | Load money into wallet | JWT + UPI-PIN |
| POST | /api/v1/payments/transfer | P2P money transfer | JWT + UPI-PIN |
| POST | /api/v1/payments/merchant | Pay merchant (QR) | JWT + UPI-PIN |
| GET | /api/v1/transactions | List transaction history | JWT |
| GET | /api/v1/transactions/{id} | Transaction detail/status | JWT |
| POST | /api/v1/payments/request | Request money (collect) | JWT |
| POST | /api/v1/payments/{id}/approve | Approve collect request | JWT + UPI-PIN |
| POST | /api/v1/payments/{id}/reject | Reject collect request | JWT |
Payment Transfer Request/Response
// POST /api/v1/payments/transfer
// Request
{
"idempotency_key": "txn_abc123_def456_unique",
"sender_vpa": "user@phonepe",
"receiver_vpa": "merchant@paytm",
"amount": 1500.00,
"currency": "INR",
"description": "Payment for order #98765",
"upi_pin_hash": "encrypted_pin_hash_here"
}
// Response - Success
{
"status": "success",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"upi_rrn": "412345678901",
"amount": 1500.00,
"receiver_vpa": "merchant@paytm",
"receiver_name": "Rajesh Store",
"status": "COMPLETED",
"completed_at": "2026-07-14T10:30:05.123Z",
"bank_ref": "HDFC0001234"
}
}
// Response - Pending
{
"status": "pending",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440001",
"status": "PENDING",
"message": "Your transaction is being processed"
}
}
6. High-Level Architecture
Service Communication Patterns
The platform uses two primary communication patterns depending on latency and consistency requirements. Synchronous gRPC is used for operations where the client needs an immediate response — payment processing, balance checks, VPA resolution, and authentication. These calls have strict timeout budgets (typically 500ms-2s) and use circuit breakers to prevent cascade failures. Asynchronous Kafka messaging is used for event propagation, notification delivery, settlement processing, fraud feature updates, and analytics pipelines. Kafka provides at-least-once delivery with idempotent consumers, ensuring no event is lost even during service restarts.
The CQRS (Command Query Responsibility Segregation) pattern separates write operations (payments, wallet debits) from read operations (transaction history, balance display). Writes go to the primary MySQL database with strong consistency guarantees. Reads are served from read replicas and Redis caches, optimized for low latency. A change data capture (CDC) pipeline using Debezium reads the MySQL binary log and publishes change events to Kafka, which downstream consumers use to update Elasticsearch (for search), Redis (for caching), and analytics data warehouses. This separation allows us to scale read and write workloads independently.
Technology Stack at Scale
| Layer | Technology | Why This Choice |
|---|---|---|
| Mobile App | React Native + Kotlin/Swift native modules | Cross-platform with native performance for security-critical flows |
| API Gateway | Kong / Envoy | Rate limiting, authentication, request transformation, SSL termination |
| Core Services | C# / .NET 8 + ASP.NET Core | High performance, strong typing, excellent async support |
| Message Bus | Apache Kafka (3-broker minimum per cluster) | Durable, ordered, replayable event streaming |
| Primary Database | MySQL 8.0 with InnoDB | ACID compliance, mature ecosystem, strong community |
| Cache Layer | Redis Cluster 7.x | Sub-millisecond reads, atomic operations, pub/sub |
| Search | Elasticsearch 8.x | Full-text search on transactions, log aggregation |
| Object Storage | AWS S3 / Azure Blob | Documents, KYC images, settlement files, backups |
| Container Orchestration | Kubernetes (EKS/AKS) | Auto-scaling, rolling updates, service mesh support |
| Observability | Prometheus + Grafana + Jaeger | Metrics, dashboards, distributed tracing |
7. UPI Protocol Deep Dive
Understanding UPI's architecture is essential for building a compliant payment platform. UPI is operated by the National Payments Corporation of India (NPCI) and connects banks through a central switch.
Key UPI Components
| Component | Role | Example |
|---|---|---|
| Payer PSP | Payer's UPI app provider | PhonePe (Yes Bank) |
| Payee PSP | Payee's bank/app | HDFC Bank / Google Pay |
| NPCI | Central switch, routes messages | NPCI UPI Switch |
| Remitter Bank | Debitor's bank | SBI, ICICI |
| Beneficiary Bank | Creditor's bank | HDFC, Axis |
| VPA | Virtual Payment Address | user@phonepe |
| QR Code | Static/Dynamic payment request | UPI QR at merchant |
UPI Message Flow
IMPS vs NEFT vs RTGS
| Feature | IMPS | NEFT | RTGS |
|---|---|---|---|
| Settlement | Real-time 24x7 | Batch (every 30 min) | Real-time (business hrs) |
| Min Amount | ₹1 | ₹1 | ₹2 lakh |
| Used By UPI | Yes (primary) | Settlement only | No |
| Latency | < 5 seconds | 5-30 minutes | 30-60 seconds |
| Charges | Nominal | Nominal | Higher for large amounts |
8. Payment Flow (P2P & P2M)
P2P Payment Flow
P2M (Merchant) Payment Flow via QR Code
9. Wallet System
The wallet acts as a prepaid instrument allowing users to load money once and make multiple payments without repeatedly entering UPI PIN for small amounts (within RBI limits).
Wallet Operations
public class WalletService
{
private readonly IDbConnection _db;
private readonly IDistributedLock _lock;
private readonly IMessageBus _kafka;
public async Task<WalletLoadResult> LoadMoneyAsync(
long userId, decimal amount, string sourceBankAccountId)
{
var wallet = await GetWalletWithLockAsync(userId);
if (wallet.Balance + amount > 200_000m)
throw new LimitExceededException(
"Wallet balance cannot exceed 200000 per RBI guidelines");
var upiResponse = await _upiGateway.InitiatePullAsync(
sourceBankAccountId, amount, "Wallet Load");
if (upiResponse.Status != PaymentStatus.Success)
return WalletLoadResult.Failed(upiResponse.FailureReason);
await _db.ExecuteAsync(
@"UPDATE wallets
SET balance = balance + @Amount,
version = version + 1,
updated_at = NOW()
WHERE user_id = @UserId AND status = 'ACTIVE'",
new { Amount = amount, UserId = userId });
await RecordWalletTxnAsync(userId, WalletTxnType.LOAD,
amount, upiResponse.UpiRrn);
await _kafka.PublishAsync("wallet.events",
new WalletCreditedEvent(userId, amount, upiResponse.UpiRrn));
return WalletLoadResult.Success(wallet.Balance + amount);
}
public async Task<WalletDebitResult> DebitAsync(
long userId, decimal amount, string transactionId)
{
var wallet = await GetWalletWithLockAsync(userId);
if (wallet.Balance - wallet.LockedAmount < amount)
return WalletDebitResult.InsufficientFunds();
var affected = await _db.ExecuteAsync(
@"UPDATE wallets
SET balance = balance - @Amount,
version = version + 1,
updated_at = NOW()
WHERE user_id = @UserId
AND version = @Version
AND balance - locked_amount >= @Amount
AND status = 'ACTIVE'",
new { Amount = amount, UserId = userId, Version = wallet.Version });
if (affected == 0)
throw new OptimisticLockException("Concurrent modification detected");
await RecordWalletTxnAsync(userId, WalletTxnType.DEBIT, amount, transactionId);
return WalletDebitResult.Success(wallet.Balance - amount);
}
}
Wallet Balance Flow
10. QR Code & Merchant Payments
QR Code Types
| Type | Description | Use Case |
|---|---|---|
| Static QR | Contains only VPA. Amount entered by payer. | Small shops, street vendors |
| Dynamic QR | Contains VPA + amount + merchant ref. | Retail stores, restaurants |
| Bharat QR | EMV standard, supports cards + UPI | Large merchants |
| UPI Intent | Deep link that opens UPI app directly | App-to-app payments |
Dynamic QR Generation
public class QrCodeService
{
public string GenerateUpiQrPayload(
string vpa, string merchantName, decimal? amount,
string transactionNote, string merchantCode)
{
var payload = new StringBuilder();
payload.Append("000201"); // Payload Format
payload.Append("010212"); // Dynamic QR (12)
payload.Append($"54{amount?.ToString("F2").Length:D2}{amount?.ToString("F2")}");
payload.Append("5802IN"); // Country: India
payload.Append($"02{merchantCode.Length:D2}{merchantCode}");
var vpaInfo = $"00{vpa.Length:D2}{vpa}" +
$"01{merchantName.Length:D2}{merchantName}";
payload.Append($"26{vpaInfo.Length:D2}{vpaInfo}");
payload.Append("6304"); // CRC placeholder
var crc = CalculateCRC16(payload.ToString());
payload.Append(crc);
return payload.ToString();
}
private string CalculateCRC16(string data)
{
ushort crc = 0xFFFF;
foreach (char c in data)
{
crc ^= (ushort)(c << 8);
for (int i = 0; i < 8; i++)
crc = (crc & 0x8000) != 0
? (ushort)((crc << 1) ^ 0x1021)
: (ushort)(crc << 1);
}
return crc.ToString("X4");
}
}
Merchant Settlement Flow
11. Transaction Processing & ACID
Payment systems require strict ACID guarantees. A transaction must either fully complete or fully rollback — partial states can lead to money loss or compliance violations.
Transaction State Machine
ACID Implementation with Outbox Pattern
public class TransactionProcessor
{
public async Task<PaymentResult> ProcessPaymentAsync(PaymentCommand command)
{
var existing = await _cache.GetAsync<PaymentResult>(
$"txn:idem:{command.IdempotencyKey}");
if (existing != null) return existing;
using var transaction = await _db.BeginTransactionAsync(
IsolationLevel.Serializable);
try
{
var duplicate = await _db.QueryFirstOrDefaultAsync<string>(
"SELECT transaction_id FROM transactions WHERE idempotency_key = @Key",
new { Key = command.IdempotencyKey }, transaction);
if (duplicate != null)
return PaymentResult.Duplicate(duplicate);
var wallet = await _db.QueryFirstAsync<Wallet>(
@"SELECT * FROM wallets WHERE user_id = @UserId FOR UPDATE",
new { UserId = command.SenderId }, transaction);
if (wallet.Balance < command.Amount)
throw new InsufficientFundsException();
var txnId = Guid.NewGuid().ToString();
await _db.ExecuteAsync(
@"INSERT INTO transactions
(transaction_id, idempotency_key, sender_id, receiver_id,
sender_vpa, receiver_vpa, amount, txn_type, status, created_at)
VALUES (@TxnId, @IdemKey, @SenderId, @ReceiverId,
@SenderVpa, @ReceiverVpa, @Amount, @Type, 'INITIATED', NOW())",
new { TxnId = txnId, command.IdempotencyKey,
command.SenderId, command.ReceiverId,
command.SenderVpa, command.ReceiverVpa,
command.Amount, Type = command.TransactionType },
transaction);
await _db.ExecuteAsync(
@"UPDATE wallets SET balance = balance - @Amount,
version = version + 1
WHERE user_id = @UserId AND version = @Version",
new { Amount = command.Amount, UserId = command.SenderId,
Version = wallet.Version }, transaction);
await _db.ExecuteAsync(
@"INSERT INTO outbox_events (event_id, aggregate_type, aggregate_id,
event_type, payload, created_at, processed)
VALUES (@Id, 'TRANSACTION', @TxnId, 'PAYMENT_INITIATED',
@Payload, NOW(), false)",
new { Id = Guid.NewGuid(), TxnId = txnId,
Payload = JsonSerializer.Serialize(command) },
transaction);
await transaction.CommitAsync();
await _kafka.PublishAsync("payments.initiated",
new PaymentInitiatedEvent(txnId, command));
return PaymentResult.Pending(txnId);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "Payment processing failed for {IdemKey}",
command.IdempotencyKey);
throw;
}
}
}
12. Idempotency & Duplicate Prevention
In distributed systems, network partitions can cause retried requests. For payment systems, a duplicate debit can be catastrophic. We implement three layers of idempotency.
Multi-Layer Idempotency Strategy
| Layer | Mechanism | Scope | Latency |
|---|---|---|---|
| Layer 1 | Redis fast-check (TTL: 24h) | Recent duplicates | < 1ms |
| Layer 2 | MySQL unique constraint on idempotency_key | All historical | < 10ms |
| Layer 3 | Client-generated UUIDv7 (time-ordered) | Deduplication at source | 0ms (client) |
public class IdempotencyGuard
{
public async Task<IdempotencyResult> CheckAndReserveAsync(
string idempotencyKey, string requestId)
{
var redisResult = await _redis.SetAddAsync(
$"idem:{idempotencyKey}", requestId, TimeSpan.FromHours(24));
if (!redisResult)
{
var existing = await GetExistingResultAsync(idempotencyKey);
if (existing != null)
return IdempotencyResult.Duplicate(existing);
return IdempotencyResult.Processing();
}
return IdempotencyResult.Allowed();
}
}
13. Settlement & Reconciliation
Settlement is the process of transferring money from the buyer's bank to the merchant's bank. Reconciliation ensures every transaction in our system matches NPCI and bank records.
Settlement Process
Reconciliation States
| Our Status | NPCI Status | Bank Status | Resolution |
|---|---|---|---|
| COMPLETED | SUCCESS | CREDITED | Matched - OK |
| COMPLETED | SUCCESS | PENDING | Wait for bank processing |
| PENDING | SUCCESS | CREDITED | Update our status |
| PENDING | FAILED | DEBITED | Reverse + alert ops |
| COMPLETED | FAILED | N/A | Refund to user |
| FAILED | SUCCESS | CREDITED | Manual investigation |
| COMPLETED | N/A | N/A | Reconcile with T+2 file |
14. Fraud Detection & Risk Scoring
Fraud detection must happen in < 100ms without impacting payment latency. We use a multi-stage ML pipeline with feature engineering on cached data.
Risk Signals
| Signal | Weight | Detection |
|---|---|---|
| Transaction velocity | High | >10 txns in 1 hour from same device |
| Device fingerprint change | Medium | New device / jailbroken / rooted |
| Amount anomaly | Medium | 5x above user's 30-day average |
| Geo-velocity | High | Impossible travel (2 cities in 10 min) |
| New payee | Low | First-time payment to this VPA |
| Time-of-day | Low | Transaction at unusual hour (3 AM) |
| IP reputation | High | Known VPN/proxy/Tor exit node |
Velocity Rules Implementation
public class VelocityChecker
{
private readonly IDistributedCache _redis;
public async Task<bool> IsVelocityExceededAsync(
long userId, decimal amount, string deviceId)
{
var userKey = $"vel:user:{userId}";
var deviceKey = $"vel:device:{deviceId}";
var hourCount = await _redis.StringIncrementAsync($"{userKey}:1h");
if (hourCount == 1)
await _redis.KeyExpireAsync($"{userKey}:1h", TimeSpan.FromHours(1));
var dayCount = await _redis.StringIncrementAsync($"{deviceKey}:24h");
if (dayCount == 1)
await _redis.KeyExpireAsync($"{deviceKey}:24h", TimeSpan.FromHours(24));
var hourAmount = await _redis.StringIncrementAsync(
$"{userKey}:amt:1h", (long)(amount * 100));
if (hourCount > 10) return true;
if (dayCount > 50) return true;
if (hourAmount > 20_000_00) return true;
return false;
}
}
15. KYC & Compliance
India's RBI mandates three tiers of KYC for prepaid payment instruments (PPIs):
| KYC Level | Requirements | Wallet Limit | Features |
|---|---|---|---|
| Minimum KYC | Aadhaar number + OTP | ₹10,000/month | Basic UPI + limited wallet |
| Medium KYC | Aadhaar eKYC (biometric) | ₹1,00,000/year | Full wallet, bill payments |
| Full KYC | Aadhaar + PAN + Video KYC | ₹2,00,000 balance | All features, merchant payments |
16. Notification System
Every payment generates at least 3 notifications: sender debit alert, receiver credit alert, and merchant notification. At 10B monthly txns, that's 30B+ notifications/month.
Notification Architecture
Notification Priorities
| Event | SMS | Push | In-App | |
|---|---|---|---|---|
| Debit successful | Yes | Yes | No | Yes |
| Credit received | Yes | Yes | No | Yes |
| Payment failed | No | Yes | No | Yes |
| Fraud alert | Yes | Yes | Yes | Yes |
| Settlement credited | No | Yes | Yes | Yes |
| OTP | Yes | No | No | No |
SMS Delivery at Scale
At 2 billion SMS per month, delivery reliability and cost optimization are critical. The platform maintains relationships with 5+ SMS providers (MSG91, Gupshup, Twilio, TextLocal, BICS) and implements intelligent routing based on carrier, delivery rate, cost, and latency. Domestic Indian numbers route through MSG91/Gupshup (₹0.15-0.25 per SMS) while international numbers use Twilio/BICS. A carrier lookup service pre-validates phone numbers to route DND-registered numbers through transactional routes (which bypass DND restrictions for financial alerts). The SMS template engine uses Mustache templates cached in Redis, supporting 15+ Indian languages. Each SMS is tracked with a delivery receipt (DLR) callback from the provider, and failed deliveries are retried through the next provider in the failover chain within 30 seconds.
Push Notification Strategy
Push notifications through FCM (Android) and APNs (iOS) handle the bulk of real-time alerts with zero marginal cost. The platform maintains a device token registry per user, supporting multiple devices. When a payment notification arrives, the system sends to all active devices simultaneously. A smart batching mechanism groups non-critical notifications (promotional, weekly summary) into a single push to avoid notification fatigue. The platform tracks push delivery metrics (open rate, click-through rate) and uses these to optimize notification frequency and timing. Users can customize their notification preferences per channel and per event type, stored in a user_settings table with Redis caching for fast lookup during notification dispatch.
Notification Cost Optimization
At the scale of 30 billion notifications per month, even small optimizations yield significant savings. By replacing SMS with push notifications for users who have the app installed (approximately 80% of active users), the platform saves roughly ₹12 crore per month on SMS costs alone. Email notifications for transaction receipts and monthly statements are batched into daily digests rather than sent individually, reducing email sending costs by 40%. The notification analytics dashboard tracks cost per notification, delivery rate, and user engagement metrics, enabling continuous optimization of the notification strategy.
17. Bill Payments & Recharges
Bill payments and recharges are a significant use case, processed through NPCI's Bharat Bill Payment System (BBPS). The flow involves fetching bills, displaying them, and processing payment through the same UPI rails.
Bill Payment Flow
Supported categories include electricity (state boards), water, gas, DTH recharge, mobile recharge, broadband, insurance premiums, loan EMIs, and municipal taxes. Each biller integration requires a unique biller code mapped through BBPS.
18. Investment & Insurance Products
Modern UPI apps have evolved into full-stack financial platforms. PhonePe offers mutual funds, gold, insurance, and SIP investments — all accessible through the same user interface.
Investment Products Architecture
| Product | Partner | Settlement | Regulatory |
|---|---|---|---|
| Mutual Funds (SIP/Lumpsum) | AMCs via BSE StAR | T+1 via RTA | SEBI registered |
| Digital Gold | SafeGold / Augmont | Instant | No specific license |
| Term Insurance | Insurance partners | One-time / annual | IRDAI regulated |
| Health Insurance | Insurance partners | Annual premium | IRDAI regulated |
| NPS | NSDL / KFintech | As per NPS cycles | PFRDA regulated |
The key architectural consideration is partner isolation: each investment product's backend is a separate microservice with its own database, communicating with the core platform through well-defined event-driven contracts.
Super App Strategy
PhonePe's transformation from a pure UPI app into a super app is a strategic masterclass. The platform integrates ride-hailing (PhonePe), food delivery, hotel booking, and local commerce — all powered by the same wallet and UPI infrastructure. From an architecture standpoint, the super app strategy means the platform must support third-party SDK integration with strict data isolation, rate limiting per partner, and independent deployment cycles. Each third-party service runs in its own Kubernetes namespace with resource quotas, network policies preventing cross-namespace communication, and separate logging and monitoring pipelines. The wallet serves as the universal payment layer, enabling seamless cross-partner transactions without users needing to switch between apps.
The key performance indicator for a super app is the transaction frequency per user per month. While a pure UPI app might see 15-20 transactions per user per month, a super app with integrated services can push this to 50-80 transactions. This dramatically improves unit economics, as the fixed infrastructure cost is spread across more transactions. The recommendation engine, powered by user transaction history and spending patterns, surfaces relevant services at the right time — for example, suggesting bill payment reminders after detecting a salary credit.
19. Database Design & Sharding
Sharding Strategy
Sharding Rules
| Table | Shard Key | Strategy | Partitions |
|---|---|---|---|
| users | user_id | Consistent hash 256 virtual nodes to 64 physical shards | None (hot data) |
| transactions | sender_id | Same shard as user + monthly range partition | Monthly, archived after 2 years |
| wallets | user_id | Co-located with user (same shard) | None |
| merchants | merchant_id | Consistent hash to 64 shards | None |
| settlements | merchant_id | Same shard as merchant + date partition | Daily, aggregated after 90 days |
Cross-Shard Queries
When a user in Shard 1 pays a user in Shard 2, the Payment Service coordinates using a saga pattern: debit from Shard 1, then credit to Shard 2, with compensation on failure. Cross-shard transaction history is served through a denormalized read model in Elasticsearch.
20. Caching Strategy
Multi-Level Cache Architecture
| Cache Level | Technology | TTL | Use Cases |
|---|---|---|---|
| L1: In-Process | MemoryCache (C#) | 30 seconds | User profile, VPA resolution |
| L2: Distributed | Redis Cluster | 5-60 minutes | Balances, session, rate limits |
| L3: CDN Edge | CloudFront | 1-24 hours | Static assets, bank logos, FAQs |
Cache Invalidation Strategy
public class CacheManager
{
public async Task<decimal> GetBalanceAsync(long userId)
{
var cacheKey = $"balance:{userId}";
var cached = await _redis.StringGetAsync(cacheKey);
if (cached.HasValue)
return decimal.Parse(cached!);
var balance = await _db.QuerySingleAsync<decimal>(
"SELECT balance FROM wallets WHERE user_id = @UserId",
new { UserId = userId });
await _redis.StringSetAsync(cacheKey,
balance.ToString("F2"), TimeSpan.FromMinutes(5));
return balance;
}
public async Task OnBalanceChangedAsync(long userId, decimal newBalance)
{
await _redis.StringSetAsync(
$"balance:{userId}", newBalance.ToString("F2"),
TimeSpan.FromMinutes(5));
await _redis.KeyDeleteAsync($"user:{userId}");
await _redis.KeyDeleteAsync($"user:profile:{userId}");
await _kafka.PublishAsync("cache.invalidation",
new CacheInvalidationEvent("balance", userId));
}
}
Cache Stampede Prevention
When a popular cached item expires simultaneously for many users (for example, a cached bank logo or a trending merchant's QR code), it can cause a cache stampede — hundreds of concurrent database queries for the same key. The platform prevents this using probabilistic early expiration: cache items have a 10% chance of being refreshed 30 seconds before their actual TTL expires, spreading the refresh load over time. Additionally, Redis SETNX-based locks ensure only one process refreshes a given cache key at a time while other concurrent requests wait (with a 100ms timeout) and then read the refreshed value. For balance caching specifically, we use a write-through strategy rather than read-through, since balance changes are infrequent but reads are extremely frequent. The balance is written to Redis immediately when it changes (via the Wallet service), avoiding the need for cache-miss-triggered database reads during payment processing.
Cache Sizing and Eviction
The Redis cluster stores approximately 128GB of cached data across 16 nodes. Memory is managed using the allkeys-lru eviction policy, which automatically removes least-recently-used keys when memory pressure is detected. Critical keys like user balances and session tokens are marked with the noevict flag to prevent them from being evicted. Cache hit rates are monitored per key prefix: balances achieve 98.5% hit rates, VPA resolution 99.2%, user profiles 95%, and transaction status lookups 85% (many transactions are queried only once). The cache hit rate directly impacts database load — every 1% improvement in cache hit rate reduces MySQL query load by approximately 3 million queries per day.
21. Security & Encryption (PCI-DSS)
Security Layers
| Layer | Mechanism | Standard |
|---|---|---|
| Transport | TLS 1.3 with certificate pinning | PCI-DSS 4.0 |
| Application | JWT + OAuth 2.0 + UPI PIN | OAuth 2.1 |
| Data at Rest | AES-256-GCM encryption | PCI-DSS, RBI |
| Key Management | AWS KMS / HSM (FIPS 140-2 Level 3) | PCI-DSS |
| Tokenization | Card numbers to tokens (never stored) | PCI-DSS |
| API Security | HMAC-SHA256 request signing | Industry best practice |
| Device Security | Device binding, jailbreak detection | RBI guidelines |
Data Encryption Implementation
public class EncryptionService
{
private readonly IKeyManagementService _kms;
public EncryptedPayload Encrypt(string plaintext, string context)
{
var dek = _kms.GetDataEncryptionKey(context);
using var aes = Aes.Create();
aes.Key = dek.Key;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipherBytes = encryptor.TransformFinalBlock(
plainBytes, 0, plainBytes.Length);
return new EncryptedPayload
{
Ciphertext = Convert.ToBase64String(cipherBytes),
IV = Convert.ToBase64String(aes.IV),
KeyVersion = dek.Version,
Algorithm = "AES-256-CBC"
};
}
public string Decrypt(EncryptedPayload payload, string context)
{
var dek = _kms.GetDataEncryptionKey(context, payload.KeyVersion);
using var aes = Aes.Create();
aes.Key = dek.Key;
aes.IV = Convert.FromBase64String(payload.IV);
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
var cipherBytes = Convert.FromBase64String(payload.Ciphertext);
var plainBytes = decryptor.TransformFinalBlock(
cipherBytes, 0, cipherBytes.Length);
return Encoding.UTF8.GetString(plainBytes);
}
}
22. Multi-Region & Disaster Recovery
Active-Active Architecture
Disaster Recovery Targets
| Metric | Target | Mechanism |
|---|---|---|
| RPO (Recovery Point) | < 1 second | Synchronous cross-region DB replication |
| RTO (Recovery Time) | < 30 seconds | Automated failover with health checks |
| Availability | 99.99% | Active-active + automatic routing |
| Data Durability | 99.999999999% | Multi-AZ replication + S3 backups |
Failover Testing and Chaos Engineering
A payment platform cannot rely on theoretical disaster recovery capabilities — they must be tested regularly under realistic conditions. The platform runs quarterly chaos engineering exercises using a modified Chaos Monkey that randomly terminates service instances, injects network latency between regions, and simulates NPCI switch outages. Each exercise validates that failover completes within the 30-second RTO and that no transactions are lost during the transition. The chaos engineering team maintains a game day playbook that covers 15 failure scenarios, including: full region failure, database primary crash, Kafka broker loss, Redis cluster split, DNS resolution failure, NPCI connectivity loss, and simultaneous multi-service failures. After each exercise, the team publishes a chaos report with findings, remediation actions, and improvements to the monitoring and alerting system.
The platform also implements automated canary deployments that gradually route traffic to new versions while monitoring error rates and latency. If any anomaly is detected (error rate > 0.1% or P99 latency > 500ms increase), the deployment is automatically rolled back within 60 seconds. This prevents bad deployments from impacting the production payment flow. The canary analysis uses statistical significance testing (not just simple thresholds) to detect subtle regressions that might be missed by simple percentage-based rules. Deployment windows are restricted to low-traffic periods (2 AM - 6 AM IST) for critical payment services, while non-critical services can be deployed at any time with the canary protection.
23. Cost Estimation
Monthly Infrastructure Cost (at scale)
| Component | Configuration | Monthly Cost (INR) |
|---|---|---|
| Compute (EKS/K8s) | 200+ pods, m5.2xlarge x 50 | ₹45,00,000 |
| MySQL (RDS) | 64 shards x r5.4xlarge + replicas | ₹38,00,000 |
| Redis Cluster | 256GB cluster, r6g.xlarge x 16 | ₹8,00,000 |
| Kafka | 50-broker cluster, m5.2xlarge | ₹12,00,000 |
| Network / CDN | 50TB/month data transfer | ₹5,00,000 |
| SMS (2B/month) | ₹0.20 per SMS | ₹40,00,000 |
| NPCI Transaction Fees | ₹0.50 per 1000 txns | ₹5,00,000 |
| Security / Compliance | HSM, PCI audit, WAF | ₹3,00,000 |
| Total Monthly | ₹1,56,00,000 |
24. Interview Q&A (10+ Questions)
Q1: How do you handle a situation where the user's bank debits money but our system shows payment pending?
Q2: How do you prevent double-spending when the same wallet balance is used for two concurrent transactions?
Q3: Design the idempotency mechanism for UPI payments end-to-end.
Q4: How would you design the settlement system for 10 million merchants?
Q5: How do you handle a network partition where the user's device disconnects after debit but before receiving confirmation?
Q6: Explain how you'd shard the transaction database for 10 billion monthly transactions.
Q7: How do you implement real-time fraud detection without adding latency to payment processing?
Q8: Design the collect request (pull payment) flow where a merchant requests money from a customer.
Q9: How would you handle a festival day when traffic is 5x normal (Diwali, New Year)?
Q10: Design the notification system to handle 30 billion notifications per month.
Q11: How do you ensure data consistency when the wallet service and payment service need to update atomically across services?
Q12: What happens when NPCI is down and cannot process payments?
25. Full C# Implementation — UPI Payment Engine
Below is a complete, production-grade C# implementation of the core UPI payment engine, demonstrating the payment flow from initiation through NPCI processing to settlement.
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace UpiPaymentEngine
{
public enum TransactionType { P2P, P2M, WalletLoad, WalletPay, BillPay, Refund, Cashback }
public enum TransactionStatus { Initiated, Validating, Pending, Completed, Failed, Reversed, Timeout, Refunded }
public enum RiskLevel { Low, Medium, High, Critical }
public class PaymentCommand
{
public string IdempotencyKey { get; set; } = Guid.NewGuid().ToString("N");
public long SenderId { get; set; }
public long ReceiverId { get; set; }
public string SenderVpa { get; set; } = string.Empty;
public string ReceiverVpa { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Currency { get; set; } = "INR";
public string Description { get; set; } = string.Empty;
public TransactionType Type { get; set; } = TransactionType.P2P;
public string DeviceId { get; set; } = string.Empty;
public string IpAddress { get; set; } = string.Empty;
public string UpiPinHash { get; set; } = string.Empty;
}
public class Transaction
{
public string TransactionId { get; set; } = Guid.NewGuid().ToString();
public string IdempotencyKey { get; set; } = string.Empty;
public long SenderId { get; set; }
public long ReceiverId { get; set; }
public string SenderVpa { get; set; } = string.Empty;
public string ReceiverVpa { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Currency { get; set; } = "INR";
public TransactionType Type { get; set; }
public TransactionStatus Status { get; set; }
public string? UpiRrn { get; set; }
public string? NpcTransactionId { get; set; }
public string? BankRef { get; set; }
public string? FailureReason { get; set; }
public decimal RiskScore { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
public class Wallet
{
public long WalletId { get; set; }
public long UserId { get; set; }
public decimal Balance { get; set; }
public decimal LockedAmount { get; set; }
public long Version { get; set; }
public string Status { get; set; } = "ACTIVE";
}
public class FraudAssessment
{
public decimal Score { get; set; }
public RiskLevel Level { get; set; }
public List<string> TriggeredRules { get; set; } = new();
public bool IsBlocked => Score >= 0.8m;
public bool RequiresStepUp => Score >= 0.3m && Score < 0.8m;
}
public class PaymentResult
{
public bool Success { get; set; }
public bool IsDuplicate { get; set; }
public string TransactionId { get; set; } = string.Empty;
public string? UpiRrn { get; set; }
public string? FailureReason { get; set; }
public TransactionStatus Status { get; set; }
public decimal? UpdatedBalance { get; set; }
public static PaymentResult Ok(Transaction txn, decimal balance) =>
new() { Success = true, TransactionId = txn.TransactionId,
UpiRrn = txn.UpiRrn, Status = txn.Status, UpdatedBalance = balance };
public static PaymentResult Pending(string txnId) =>
new() { Success = true, TransactionId = txnId, Status = TransactionStatus.Pending };
public static PaymentResult Duplicate(string existingTxnId) =>
new() { IsDuplicate = true, TransactionId = existingTxnId, Status = TransactionStatus.Completed };
public static PaymentResult Fail(string reason) =>
new() { Success = false, FailureReason = reason, Status = TransactionStatus.Failed };
public static PaymentResult Blocked(decimal score) =>
new() { Success = false, FailureReason = $"Blocked (risk: {score})", Status = TransactionStatus.Failed };
}
public interface IUpiGateway
{
Task<NpciResponse> InitiateDebitAsync(Transaction txn, CancellationToken ct = default);
Task<NpciResponse> CheckStatusAsync(string npcTxnId, CancellationToken ct = default);
Task<NpciResponse> InitiateRefundAsync(string upiRrn, decimal amount, CancellationToken ct = default);
}
public class NpciResponse
{
public bool Success { get; set; }
public string? UpiRrn { get; set; }
public string? BankRef { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
}
public interface IMessageBus { Task PublishAsync<T>(string topic, T message); }
public interface IEventStore { Task AppendAsync(string aggregateId, string eventType, object payload); }
public class FraudDetectionEngine
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<FraudDetectionEngine> _logger;
public FraudDetectionEngine(IConnectionMultiplexer redis,
ILogger<FraudDetectionEngine> logger)
{ _redis = redis; _logger = logger; }
public async Task<FraudAssessment> AssessAsync(
PaymentCommand cmd, Transaction txn, CancellationToken ct = default)
{
var db = _redis.GetDatabase();
var assessment = new FraudAssessment();
decimal totalScore = 0m;
var hourlyKey = $"fraud:vel:hour:{cmd.SenderId}";
var hourlyCount = await db.StringIncrementAsync(hourlyKey);
if (hourlyCount == 1)
await db.KeyExpireAsync(hourlyKey, TimeSpan.FromHours(1));
if (hourlyCount > 10)
{ totalScore += 0.3m; assessment.TriggeredRules.Add("Velocity >10 txns/hr"); }
var dailyAmountKey = $"fraud:amt:day:{cmd.SenderId}";
var dailyAmount = await db.StringIncrementAsync(dailyAmountKey, (long)(cmd.Amount * 100));
if (dailyAmount == cmd.Amount * 100)
await db.KeyExpireAsync(dailyAmountKey, TimeSpan.FromHours(24));
if (dailyAmount > 2_00_00_00)
{ totalScore += 0.3m; assessment.TriggeredRules.Add("Daily amount exceeds 2L"); }
if (!string.IsNullOrEmpty(cmd.DeviceId))
{
var deviceKey = $"fraud:device:{cmd.DeviceId}:user:{cmd.SenderId}";
var isKnown = await db.KeyExistsAsync(deviceKey);
if (!isKnown)
{ totalScore += 0.15m; assessment.TriggeredRules.Add("New device"); await db.StringSetAsync(deviceKey, "1", TimeSpan.FromDays(90)); }
}
var avgKey = $"fraud:avg_amt:{cmd.SenderId}";
var avgStr = await db.StringGetAsync(avgKey);
if (avgStr.HasValue && decimal.TryParse(avgStr, out var avgAmount) && cmd.Amount > avgAmount * 5 && cmd.Amount > 5000)
{ totalScore += 0.2m; assessment.TriggeredRules.Add($"Amount 5x avg ({avgAmount})"); }
var hour = DateTime.UtcNow.Hour;
if (hour >= 1 && hour <= 5)
{ totalScore += 0.1m; assessment.TriggeredRules.Add("Unusual hours (1-5 AM)"); }
var firstTimeKey = $"fraud:first_time:{cmd.SenderId}:{cmd.ReceiverId}";
var isFirstTime = !(await db.KeyExistsAsync(firstTimeKey));
if (isFirstTime && cmd.Amount > 10000)
{ totalScore += 0.15m; assessment.TriggeredRules.Add("High-value first-time"); }
await db.StringSetAsync(firstTimeKey, "1", TimeSpan.FromDays(180));
assessment.Score = Math.Min(totalScore, 1.0m);
assessment.Level = assessment.Score switch
{
< 0.3m => RiskLevel.Low, < 0.7m => RiskLevel.Medium,
< 0.9m => RiskLevel.High, _ => RiskLevel.Critical
};
_logger.LogInformation("Fraud: txn={TxnId} score={Score} rules={Rules}",
txn.TransactionId, assessment.Score, string.Join("; ", assessment.TriggeredRules));
var newAvg = avgStr.HasValue ? (avgAmount * 0.95m) + (cmd.Amount * 0.05m) : cmd.Amount;
await db.StringSetAsync(avgKey, newAvg.ToString("F2"), TimeSpan.FromDays(365));
return assessment;
}
}
public class IdempotencyGuard
{
private readonly IConnectionMultiplexer _redis;
public IdempotencyGuard(IConnectionMultiplexer redis) { _redis = redis; }
public async Task<(bool IsAllowed, string? ExistingTxnId)> CheckAsync(string idempotencyKey, string requestId)
{
var db = _redis.GetDatabase();
var lockKey = $"idem:{idempotencyKey}";
var acquired = await db.StringSetAsync(lockKey, requestId, TimeSpan.FromHours(24), When.NotExists);
if (acquired) return (true, null);
var txnId = await db.StringGetAsync($"idem:result:{idempotencyKey}");
return (false, txnId);
}
public async Task StoreResultAsync(string idempotencyKey, string txnId)
{
var db = _redis.GetDatabase();
await db.StringSetAsync($"idem:result:{idempotencyKey}", txnId, TimeSpan.FromHours(24));
}
}
public class WalletService
{
private readonly IDbConnection _db;
private readonly ILogger<WalletService> _logger;
public WalletService(IDbConnection db, ILogger<WalletService> logger)
{ _db = db; _logger = logger; }
public async Task<Wallet?> GetWalletAsync(long userId)
{
return await _db.QueryFirstOrDefaultAsync<Wallet>(
@"SELECT wallet_id as WalletId, user_id as UserId, balance,
locked_amount as LockedAmount, version, status
FROM wallets WHERE user_id = @UserId AND status = 'ACTIVE'",
new { UserId = userId });
}
public async Task<(bool Success, decimal NewBalance)> DebitAsync(
long userId, decimal amount, long expectedVersion, IDbTransaction? txn = null)
{
var affected = await _db.ExecuteAsync(
@"UPDATE wallets SET balance = balance - @Amount, version = version + 1, updated_at = NOW()
WHERE user_id = @UserId AND version = @Version AND (balance - locked_amount) >= @Amount AND status = 'ACTIVE'",
new { Amount = amount, UserId = userId, Version = expectedVersion }, txn);
if (affected == 0) return (false, 0);
var wallet = await GetWalletAsync(userId);
return (true, wallet?.Balance ?? 0);
}
public async Task<(bool Success, decimal NewBalance)> CreditAsync(long userId, decimal amount, IDbTransaction? txn = null)
{
var affected = await _db.ExecuteAsync(
@"UPDATE wallets SET balance = balance + @Amount, version = version + 1, updated_at = NOW()
WHERE user_id = @UserId AND status = 'ACTIVE'",
new { Amount = amount, UserId = userId }, txn);
if (affected == 0) return (false, 0);
var wallet = await GetWalletAsync(userId);
return (true, wallet?.Balance ?? 0);
}
public async Task<Wallet> CreateWalletAsync(long userId, IDbTransaction? txn = null)
{
await _db.ExecuteAsync(
@"INSERT INTO wallets (user_id, balance, locked_amount, version, status, created_at) VALUES (@UserId, 0, 0, 0, 'ACTIVE', NOW())",
new { UserId = userId }, txn);
return (await GetWalletAsync(userId))!;
}
}
public class VpaInfo
{
public long UserId { get; set; }
public string Provider { get; set; } = string.Empty;
public string BankCode { get; set; } = string.Empty;
public bool IsInternal { get; set; }
}
public class UpiPaymentEngine
{
private readonly IDbConnection _db;
private readonly IConnectionMultiplexer _redis;
private readonly IUpiGateway _upiGateway;
private readonly IMessageBus _kafka;
private readonly IEventStore _eventStore;
private readonly FraudDetectionEngine _fraudEngine;
private readonly IdempotencyGuard _idempotencyGuard;
private readonly WalletService _walletService;
private readonly ILogger<UpiPaymentEngine> _logger;
private const int MAX_RETRY_ATTEMPTS = 3;
public UpiPaymentEngine(IDbConnection db, IConnectionMultiplexer redis,
IUpiGateway upiGateway, IMessageBus kafka, IEventStore eventStore,
FraudDetectionEngine fraudEngine, IdempotencyGuard idempotencyGuard,
WalletService walletService, ILogger<UpiPaymentEngine> logger)
{
_db = db; _redis = redis; _upiGateway = upiGateway; _kafka = kafka;
_eventStore = eventStore; _fraudEngine = fraudEngine;
_idempotencyGuard = idempotencyGuard; _walletService = walletService;
_logger = logger;
}
public async Task<PaymentResult> ProcessPaymentAsync(PaymentCommand command, CancellationToken ct = default)
{
_logger.LogInformation("Processing: idemKey={Key} sender={Sender} amount={Amount}",
command.IdempotencyKey, command.SenderId, command.Amount);
var (isAllowed, existingTxnId) = await _idempotencyGuard.CheckAsync(command.IdempotencyKey, Guid.NewGuid().ToString("N"));
if (!isAllowed && existingTxnId != null) return PaymentResult.Duplicate(existingTxnId);
if (!isAllowed) return PaymentResult.Fail("Transaction being processed");
var validation = ValidateCommand(command);
if (!validation.IsValid) return PaymentResult.Fail(validation.Error!);
var receiverInfo = await ResolveVpaAsync(command.ReceiverVpa);
if (receiverInfo == null) return PaymentResult.Fail("Invalid receiver VPA");
var txn = new Transaction
{
IdempotencyKey = command.IdempotencyKey, SenderId = command.SenderId,
ReceiverId = receiverInfo.UserId, SenderVpa = command.SenderVpa,
ReceiverVpa = command.ReceiverVpa, Amount = command.Amount,
Currency = command.Currency, Type = command.Type,
Status = TransactionStatus.Initiated, CreatedAt = DateTime.UtcNow
};
using var dbTxn = await _db.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
var dbDuplicate = await _db.QueryFirstOrDefaultAsync<string>(
"SELECT transaction_id FROM transactions WHERE idempotency_key = @Key",
new { Key = command.IdempotencyKey }, dbTxn);
if (dbDuplicate != null) { await dbTxn.RollbackAsync(); return PaymentResult.Duplicate(dbDuplicate); }
var wallet = await _walletService.GetWalletAsync(command.SenderId) ?? await _walletService.CreateWalletAsync(command.SenderId, dbTxn);
if (command.Type == TransactionType.WalletPay || command.Type == TransactionType.P2P)
{
var (debitOk, _) = await _walletService.DebitAsync(command.SenderId, command.Amount, wallet.Version, dbTxn);
if (!debitOk) { await dbTxn.RollbackAsync(); return PaymentResult.Fail("Insufficient balance"); }
txn.Status = TransactionStatus.Validating;
txn.CompletedAt = DateTime.UtcNow;
}
else { txn.Status = TransactionStatus.Validating; }
await _db.ExecuteAsync(
@"INSERT INTO transactions (transaction_id, idempotency_key, sender_id, receiver_id, sender_vpa, receiver_vpa, amount, currency, txn_type, status, created_at)
VALUES (@TxnId, @IdemKey, @SenderId, @ReceiverId, @SenderVpa, @ReceiverVpa, @Amount, @Currency, @Type, @Status, @CreatedAt)",
new { txn.TransactionId, txn.IdempotencyKey, txn.SenderId, txn.ReceiverId, txn.SenderVpa, txn.ReceiverVpa, txn.Amount, txn.Currency, Type = txn.Type.ToString(), Status = txn.Status.ToString(), txn.CreatedAt }, dbTxn);
await _db.ExecuteAsync(
@"INSERT INTO outbox_events (event_id, aggregate_type, aggregate_id, event_type, payload, created_at, processed)
VALUES (@Id, 'TRANSACTION', @TxnId, 'PAYMENT_INITIATED', @Payload, NOW(), 0)",
new { Id = Guid.NewGuid(), TxnId = txn.TransactionId, Payload = JsonSerializer.Serialize(command) }, dbTxn);
await dbTxn.CommitAsync();
}
catch (Exception ex) { await dbTxn.RollbackAsync(); _logger.LogError(ex, "DB txn failed for {Key}", command.IdempotencyKey); throw; }
var assessment = await _fraudEngine.AssessAsync(command, txn, ct);
txn.RiskScore = assessment.Score;
if (assessment.IsBlocked)
{ await UpdateStatusAsync(txn.TransactionId, TransactionStatus.Failed, "Blocked by fraud"); return PaymentResult.Blocked(assessment.Score); }
NpciResponse? npciResult = null;
for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++)
{
try { npciResult = await _upiGateway.InitiateDebitAsync(txn, ct); break; }
catch (TimeoutException)
{
if (attempt == MAX_RETRY_ATTEMPTS)
{ await UpdateStatusAsync(txn.TransactionId, TransactionStatus.Timeout, "NPCI timeout"); return PaymentResult.Pending(txn.TransactionId); }
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct);
}
}
if (npciResult != null)
{
if (npciResult.Success)
{
txn.UpiRrn = npciResult.UpiRrn; txn.BankRef = npciResult.BankRef;
await UpdateStatusAsync(txn.TransactionId, TransactionStatus.Completed);
if (receiverInfo.IsInternal) await _walletService.CreditAsync(receiverInfo.UserId, command.Amount);
await _eventStore.AppendAsync(txn.TransactionId, "PAYMENT_COMPLETED", txn);
await _kafka.PublishAsync("payments.completed", new { txn.TransactionId, txn.Amount, txn.SenderVpa, txn.ReceiverVpa });
var balance = (await _walletService.GetWalletAsync(command.SenderId))?.Balance ?? 0;
return PaymentResult.Ok(txn, balance);
}
else
{
await UpdateStatusAsync(txn.TransactionId, TransactionStatus.Failed, npciResult.ErrorMessage);
if (command.Type == TransactionType.P2P || command.Type == TransactionType.WalletPay)
await _walletService.CreditAsync(command.SenderId, command.Amount);
return PaymentResult.Fail(npciResult.ErrorMessage ?? "Payment failed");
}
}
return PaymentResult.Pending(txn.TransactionId);
}
public async Task ProcessNpciCallbackAsync(string npcTxnId, string status, string? upiRrn, string? bankRef, CancellationToken ct = default)
{
var txn = await _db.QueryFirstOrDefaultAsync<Transaction>(
"SELECT * FROM transactions WHERE npc_txnid = @NpcTxnId", new { NpcTxnId = npcTxnId });
if (txn == null) return;
if (status == "SUCCESS")
{ await UpdateStatusAsync(txn.TransactionId, TransactionStatus.Completed); await _kafka.PublishAsync("payments.completed", txn); }
else if (status == "FAILED")
{ await UpdateStatusAsync(txn.TransactionId, TransactionStatus.Failed, $"NPCI: {status}"); await _kafka.PublishAsync("payments.failed", txn); }
}
public async Task<PaymentResult> RefundAsync(string transactionId, CancellationToken ct = default)
{
var txn = await _db.QueryFirstOrDefaultAsync<Transaction>(
"SELECT * FROM transactions WHERE transaction_id = @TxnId AND status = 'COMPLETED'", new { TxnId = transactionId });
if (txn == null) return PaymentResult.Fail("Transaction not found");
var refundResult = await _upiGateway.InitiateRefundAsync(txn.UpiRrn!, txn.Amount, ct);
if (refundResult.Success)
{ await UpdateStatusAsync(txn.TransactionId, TransactionStatus.Refunded); await _walletService.CreditAsync(txn.SenderId, txn.Amount); return PaymentResult.Ok(txn, txn.Amount); }
return PaymentResult.Fail("Refund failed");
}
public async Task<Transaction?> GetTransactionStatusAsync(string txnId)
{
var cacheKey = $"txn:status:{txnId}";
var cached = await _redis.GetDatabase().StringGetAsync(cacheKey);
if (cached.HasValue) return JsonSerializer.Deserialize<Transaction>(cached!);
var txn = await _db.QueryFirstOrDefaultAsync<Transaction>(
"SELECT * FROM transactions WHERE transaction_id = @TxnId", new { TxnId = txnId });
if (txn != null) await _redis.GetDatabase().StringSetAsync(cacheKey, JsonSerializer.Serialize(txn), TimeSpan.FromMinutes(5));
return txn;
}
public async Task<List<Transaction>> GetTransactionHistoryAsync(long userId, int page = 1, int pageSize = 20)
{
var offset = (page - 1) * pageSize;
return (await _db.QueryAsync<Transaction>(
@"SELECT * FROM transactions WHERE sender_id = @UserId OR receiver_id = @UserId
ORDER BY created_at DESC LIMIT @PageSize OFFSET @Offset",
new { UserId = userId, PageSize = pageSize, Offset = offset })).ToList();
}
private (bool IsValid, string? Error) ValidateCommand(PaymentCommand cmd)
{
if (cmd.Amount <= 0) return (false, "Amount must be positive");
if (cmd.Amount > 1_00_000) return (false, "Exceeds UPI limit of 1 lakh");
if (string.IsNullOrEmpty(cmd.SenderVpa)) return (false, "Sender VPA required");
if (string.IsNullOrEmpty(cmd.ReceiverVpa)) return (false, "Receiver VPA required");
if (cmd.SenderVpa == cmd.ReceiverVpa) return (false, "Cannot pay same VPA");
if (string.IsNullOrEmpty(cmd.UpiPinHash)) return (false, "UPI PIN required");
return (true, null);
}
private async Task<VpaInfo?> ResolveVpaAsync(string vpa)
{
var cacheKey = $"vpa:resolve:{vpa}";
var cached = await _redis.GetDatabase().StringGetAsync(cacheKey);
if (cached.HasValue) return JsonSerializer.Deserialize<VpaInfo>(cached!);
var info = await _db.QueryFirstOrDefaultAsync<VpaInfo>(
@"SELECT uh.user_id as UserId, uh.handle_provider as Provider, b.bank_code as BankCode
FROM upi_handles uh JOIN bank_accounts b ON b.user_id = uh.user_id AND b.is_primary = 1
WHERE uh.vpa_address = @Vpa AND uh.is_active = 1", new { Vpa = vpa });
if (info != null)
{
info.IsInternal = info.Provider == "phonepe";
await _redis.GetDatabase().StringSetAsync(cacheKey, JsonSerializer.Serialize(info), TimeSpan.FromMinutes(30));
}
return info;
}
private async Task UpdateStatusAsync(string txnId, TransactionStatus status, string? failureReason = null)
{
await _db.ExecuteAsync(
@"UPDATE transactions SET status = @Status, failure_reason = @Reason,
completed_at = CASE WHEN @Status IN ('COMPLETED','FAILED','REFUNDED') THEN NOW() ELSE completed_at END
WHERE transaction_id = @TxnId",
new { Status = status.ToString(), Reason = failureReason, TxnId = txnId });
await _redis.GetDatabase().KeyDeleteAsync($"txn:status:{txnId}");
}
}
}
27. UPI Lite & Offline Payments
UPI Lite is NPCI's answer to the growing demand for fast, low-value transactions that bypass the traditional UPI PIN entry and bank-server round-trip. Launched in 2022 and now processing hundreds of millions of transactions per month, UPI Lite enables sub-₹500 payments with near-instant confirmation — even in areas with intermittent connectivity. Understanding how to architect UPI Lite into a payment platform is essential for any PSP competing at scale.
How UPI Lite Works
Unlike standard UPI payments that require real-time bank authorization for every transaction, UPI Lite operates on a pre-funded on-device wallet model. The user loads a balance (up to ₹2,000 at a time, with a maximum stored value of ₹2,000) from their bank account via standard UPI. This balance is stored securely on the user's device in an encrypted hardware-backed keystore. When making a payment, the amount is deducted from this on-device balance without contacting the bank server. The transaction is settled later in batch via NPCI's UPI Lite settlement cycle. This architecture achieves sub-second payment confirmation because the critical path only involves local device operations and a lightweight notification to the payee — no bank debit authorization latency.
The security model relies on the device's Trusted Execution Environment (TEE) or Secure Element (SE) to protect the on-device balance. The private key used to sign UPI Lite transactions never leaves the hardware security boundary. NPCI maintains a ledger of all UPI Lite transactions, and periodic reconciliation ensures the device balance matches the server-side record. If a device is lost or compromised, the user can report it, and NPCI freezes the outstanding balance pending investigation. This creates an interesting engineering challenge: the payment platform must handle eventual consistency between the device-side balance and the server-side ledger, with reconciliation occurring in near-real-time via Kafka event streams.
Offline Payment Architecture
The offline payment flow introduces a store-and-forward pattern that is fundamentally different from standard synchronous UPI. When a user makes a UPI Lite payment while offline (for example, at a rural kiosk with no connectivity), the transaction is cryptographically signed on-device and queued in a local transaction log. Once connectivity is restored, the PSP app syncs all pending transactions to the server. The server validates each transaction's signature, checks for duplicates via idempotency keys, and submits them to NPCI in a batch. This introduces several engineering challenges: transaction ordering must be preserved, duplicate detection must handle reordered deliveries, and the server must handle a burst of queued transactions when a device reconnects after a prolonged offline period.
The PSP platform supports offline payments by implementing a transaction queue with exponential backoff on the client side and a bulk ingestion endpoint on the server side. The bulk endpoint accepts up to 50 signed transactions in a single HTTP request, reducing connection overhead for devices on slow networks. Each transaction includes a monotonic sequence number to maintain ordering, and the server processes them sequentially while checking idempotency at each step. Failed transactions in a batch are returned with error codes so the client can retry selectively — for example, if one transaction is a duplicate, only the remaining transactions are retried.
UPI Lite vs Standard UPI Comparison
| Feature | Standard UPI | UPI Lite | Offline UPI Lite |
|---|---|---|---|
| Max Transaction | ₹1,00,000 | ₹500 | ₹500 |
| UPI PIN Required | Yes | No | No |
| Bank Round-Trip | Yes (real-time) | No (local deduction) | No |
| Confirmation Latency | 1-3 seconds | < 1 second | Until online sync |
| Connectivity Required | Mandatory | For load only | Not required |
| Balance Storage | Bank server | Device TEE + NPCI ledger | Device TEE only |
| Settlement | Real-time IMPS | Batch (T+1) | Batch (T+1) |
| Reconciliation | Per transaction | Batch reconciliation | Deferred reconciliation |
C# Implementation: UPI Lite Transaction Processor
public class UpiLiteTransactionProcessor
{
private readonly IDbConnection _db;
private readonly IConnectionMultiplexer _redis;
private readonly IMessageBus _kafka;
private readonly ILogger<UpiLiteTransactionProcessor> _logger;
private const decimal MAX_LITE_BALANCE = 2000m;
private const decimal MAX_LITE_TXN = 500m;
public async Task<UpiLiteResult> ProcessLitePaymentAsync(
UpiLitePaymentCommand command, CancellationToken ct = default)
{
if (command.Amount > MAX_LITE_TXN)
return UpiLiteResult.Fail("Exceeds UPI Lite limit of ₹500");
var idemCheck = await _redis.GetDatabase().StringGetAsync(
$"upi_lite:idem:{command.IdempotencyKey}");
if (idemCheck.HasValue)
return UpiLiteResult.Duplicate(idemCheck.ToString()!);
var serverBalance = await GetLiteBalanceAsync(command.SenderId);
if (serverBalance < command.Amount)
return UpiLiteResult.Fail("Insufficient UPI Lite balance");
var txnId = Guid.NewGuid().ToString();
var affected = await _db.ExecuteAsync(
@"UPDATE upi_lite_wallets
SET balance = balance - @Amount,
pending_settlement = pending_settlement + @Amount,
version = version + 1
WHERE user_id = @UserId
AND version = @Version
AND balance >= @Amount
AND status = 'ACTIVE'",
new { Amount = command.Amount, UserId = command.SenderId,
Version = command.ServerVersion });
if (affected == 0)
return UpiLiteResult.Fail("Concurrent modification - retry");
await _db.ExecuteAsync(
@"INSERT INTO upi_lite_transactions
(txn_id, idempotency_key, sender_id, receiver_vpa, amount,
device_signature, sequence_number, status, created_at)
VALUES (@TxnId, @IdemKey, @SenderId, @ReceiverVpa, @Amount,
@Signature, @SeqNo, 'SETTLEMENT_PENDING', NOW())",
new { TxnId = txnId, command.IdempotencyKey, command.SenderId,
command.ReceiverVpa, command.Amount, command.DeviceSignature,
command.SequenceNumber });
await _redis.GetDatabase().StringSetAsync(
$"upi_lite:idem:{command.IdempotencyKey}", txnId,
TimeSpan.FromHours(48));
await _kafka.PublishAsync("upi_lite.settlement",
new UpiLiteSettlementEvent(txnId, command.SenderId,
command.ReceiverVpa, command.Amount));
return UpiLiteResult.Success(txnId, serverBalance - command.Amount);
}
public async Task ProcessBulkSyncAsync(
long userId, List<SignedLiteTransaction> queuedTxns)
{
foreach (var txn in queuedTxns.OrderBy(t => t.SequenceNumber))
{
var result = await ProcessLitePaymentAsync(
new UpiLitePaymentCommand
{
IdempotencyKey = txn.IdempotencyKey,
SenderId = userId,
ReceiverVpa = txn.ReceiverVpa,
Amount = txn.Amount,
DeviceSignature = txn.Signature,
SequenceNumber = txn.SequenceNumber
});
_logger.LogInformation("Bulk sync: {SeqNo} -> {Status}",
txn.SequenceNumber, result.IsSuccess ? "OK" : result.Error);
}
}
private async Task<decimal> GetLiteBalanceAsync(long userId)
{
var cached = await _redis.GetDatabase().StringGetAsync(
$"upi_lite:bal:{userId}");
if (cached.HasValue) return decimal.Parse(cached!);
var balance = await _db.QuerySingleAsync<decimal>(
@"SELECT balance FROM upi_lite_wallets
WHERE user_id = @UserId AND status = 'ACTIVE'",
new { UserId = userId });
await _redis.GetDatabase().StringSetAsync(
$"upi_lite:bal:{userId}", balance.ToString("F2"),
TimeSpan.FromMinutes(2));
return balance;
}
}
28. Cross-Border Payments & Remittances
India receives over $125 billion annually in remittances — the highest of any country. UPI's international expansion through NPCI International Payments Limited (NIPL) is enabling cross-border payments in Singapore, UAE, France, and beyond. Building cross-border payment capabilities into a UPI-based platform requires handling fundamentally different challenges: multi-currency conversion, real-time forex rates, regulatory compliance across jurisdictions, and correspondent banking relationships.
Cross-Border Payment Architecture
Cross-border UPI payments work through bilateral agreements between NPCI and foreign payment switches. For example, UPI-PayNow linkage allows Indian users to send money to Singapore PayNow recipients and vice versa. The PSP platform must integrate with NIPL's cross-border gateway, which handles currency conversion, compliance screening, and routing to the foreign payment switch. Unlike domestic UPI (which settles in INR via IMPS), cross-border payments involve forex conversion at the sender's bank or PSP, SWIFT/gpi messaging for settlement, and compliance checks against both Indian and foreign regulations.
The forex engine is a critical component. Exchange rates fluctuate continuously, and the PSP must provide transparent, competitive rates while managing forex risk. The platform maintains a real-time forex rate feed from multiple sources (Reuters, Bloomberg, RBI reference rates) and calculates a markup based on the transaction amount, currency pair, and market volatility. For high-value transactions (typically above ₹50,000 equivalent), the platform locks the rate at the time of quote for a configurable window (usually 30-120 seconds) to protect the user from slippage. The forex markup is a key revenue stream — typically 1-2% above the interbank rate, with volume-based discounts for high-frequency remittance corridors.
Compliance and Regulatory Screening
Every cross-border transaction must pass through multiple compliance layers. In India, the Foreign Exchange Management Act (FEMA) governs outward remittances with limits based on the purpose code — personal remittances, education fees, medical expenses, and investment each have different caps and documentation requirements. The platform integrates with the RBI's Liberalised Remittance Scheme (LRS) tracking system to enforce the $250,000 annual per-person limit. On the receiving side, the foreign payment switch enforces its own AML/CFT regulations — for example, Singapore's MAS requires source-of-funds verification for transactions above SGD 5,000. The PSP must handle multi-jurisdictional compliance screening in real-time without adding more than 200ms to the payment flow.
Cross-Border Transaction Flow
| Step | Domestic UPI | Cross-Border UPI | Additional Latency |
|---|---|---|---|
| 1. Initiation | VPA resolution | VPA + IBAN/swift code resolution | +50ms |
| 2. Compliance | Internal fraud check | FEMA + AML + foreign regulation | +150ms |
| 3. Forex | N/A (INR) | Real-time rate quote + lock | +100ms |
| 4. Debit | Domestic bank debit | Domestic bank debit | +0ms |
| 5. Routing | NPCI domestic switch | NIPL cross-border gateway | +200ms |
| 6. Settlement | IMPS (seconds) | SWIFT gpi / bilateral netting | +2-24 hours |
| 7. Credit | Instant credit | Foreign bank credit (T+0 to T+2) | Variable |
| Total E2E | 1-3 seconds | 3-10 seconds (initiation) |
C# Implementation: Cross-Border Payment Service
public class CrossBorderPaymentService
{
private readonly IDbConnection _db;
private readonly IConnectionMultiplexer _redis;
private readonly IForexEngine _forexEngine;
private readonly IComplianceScreen _complianceScreen;
private readonly INiplGateway _niplGateway;
private readonly IMessageBus _kafka;
private readonly ILogger<CrossBorderPaymentService> _logger;
public async Task<CrossBorderResult> InitiateRemittanceAsync(
RemittanceCommand command, CancellationToken ct = default)
{
var complianceResult = await _complianceScreen.ScreenAsync(
new ComplianceRequest
{
SenderCountry = "IN",
ReceiverCountry = command.DestinationCountry,
Amount = command.Amount,
SourceCurrency = "INR",
TargetCurrency = command.TargetCurrency,
PurposeCode = command.PurposeCode,
SenderId = command.SenderId
});
if (!complianceResult.IsApproved)
return CrossBorderResult.ComplianceBlocked(
complianceResult.RejectionReason);
var lrsUtilization = await GetLrsUtilizationAsync(command.SenderId);
if (lrsUtilization + command.Amount > 250_000m * await GetUsdInrRateAsync())
return CrossBorderResult.Fail(
"Exceeds annual LRS limit of $250,000");
var forexQuote = await _forexEngine.GetQuoteAsync(
sourceCurrency: "INR",
targetCurrency: command.TargetCurrency,
amount: command.Amount,
lockDuration: TimeSpan.FromSeconds(60));
var marginPaise = (forexQuote.SellRate - forexQuote.InterbankRate)
* command.Amount * 100;
_logger.LogInformation(
"Forex: {Pair} interbank={Inter} sell={Sell} margin={Margin}p",
forexQuote.Pair, forexQuote.InterbankRate,
forexQuote.SellRate, marginPaise);
var txnId = Guid.NewGuid().ToString();
await _db.ExecuteAsync(
@"INSERT INTO cross_border_transactions
(txn_id, sender_id, source_amount, source_currency,
target_amount, target_currency, forex_rate, forex_margin,
purpose_code, destination_country, compliance_status,
status, created_at)
VALUES (@TxnId, @SenderId, @SourceAmt, 'INR',
@TargetAmt, @TargetCcy, @FxRate, @FxMargin,
@PurposeCode, @DestCountry, 'APPROVED',
'INITIATED', NOW())",
new { TxnId = txnId, command.SenderId,
SourceAmt = command.Amount,
TargetAmt = forexQuote.ConvertedAmount,
TargetCcy = command.TargetCurrency,
FxRate = forexQuote.SellRate,
FxMargin = marginPaise / 100m,
command.PurposeCode, command.DestinationCountry });
var niplResult = await _niplGateway.InitiateTransferAsync(
new NiplTransferRequest
{
TransactionId = txnId,
SenderVpa = command.SenderVpa,
ReceiverBic = command.ReceiverBic,
ReceiverIban = command.ReceiverIban,
SourceAmount = command.Amount,
TargetAmount = forexQuote.ConvertedAmount,
TargetCurrency = command.TargetCurrency,
ForexRate = forexQuote.SellRate,
RateRefId = forexQuote.QuoteId,
PurposeCode = command.PurposeCode
}, ct);
if (!niplResult.IsSuccess)
{
await UpdateStatusAsync(txnId, "FAILED", niplResult.Error);
return CrossBorderResult.Fail(niplResult.Error);
}
await UpdateStatusAsync(txnId, "PROCESSING");
await _kafka.PublishAsync("cross_border.initiated",
new CrossBorderInitiatedEvent(txnId, command.SenderId,
command.DestinationCountry, command.Amount,
forexQuote.ConvertedAmount, command.TargetCurrency));
return CrossBorderResult.Success(txnId, forexQuote);
}
private async Task<decimal> GetLrsUtilizationAsync(long userId)
{
return await _db.QuerySingleAsync<decimal>(
@"SELECT COALESCE(SUM(source_amount), 0)
FROM cross_border_transactions
WHERE sender_id = @UserId
AND YEAR(created_at) = YEAR(NOW())
AND status IN ('INITIATED','PROCESSING','COMPLETED')",
new { UserId = userId });
}
private async Task UpdateStatusAsync(string txnId, string status,
string? error = null)
{
await _db.ExecuteAsync(
@"UPDATE cross_border_transactions
SET status = @Status, failure_reason = @Error,
updated_at = NOW()
WHERE txn_id = @TxnId",
new { TxnId = txnId, Status = status, Error = error });
}
}
29. QR Code Security & Fraud Prevention
QR code payments are the most widely used merchant payment method in India, with over 300 million QR codes deployed across the country. However, QR codes are also one of the most vulnerable attack surfaces in the payment ecosystem. QR code fraud — including code tampering, overlay attacks, and social engineering through malicious QR generation — costs Indian PSPs hundreds of crores annually. Building a robust QR security layer requires understanding both the technical attack vectors and the behavioral patterns of fraudsters.
QR Code Attack Vectors
The most common QR code attacks fall into three categories. QR code replacement (sticker overlay) is where a fraudster physically places their own QR code sticker over a legitimate merchant's QR code. When customers scan the altered code, their payment goes to the fraudster's account instead of the merchant's. Dynamic QR injection occurs when malware on a merchant's POS device or app replaces the legitimate VPA in the QR payload with a fraudulent VPA at the moment of display. Social engineering attacks involve sending QR codes via messaging apps with misleading descriptions like "Claim your cashback" that initiate unauthorized payments. The PSP must implement detection mechanisms for all three vectors, combining technical controls with user education.
Dynamic QR generation with time-bound tokens is the most effective technical countermeasure against QR replacement. Instead of displaying a static QR code that remains valid indefinitely, the merchant's system generates a new QR code every 30-60 seconds containing a signed, time-limited token. When the customer scans and pays, the PSP validates the token's signature, checks the expiry timestamp, and verifies that the payment matches the expected amount and merchant. If the QR has expired or the token is invalid, the payment is rejected. This renders physical QR code overlays ineffective because the fraudster's sticker QR will always be expired or have an invalid token.
Merchant QR Verification Architecture
The verification layer also implements geolocation and velocity checks specific to QR payments. If a merchant's QR code is scanned from a device in a different city than the merchant's registered location, the transaction is flagged for additional verification. Similarly, if the same static QR code receives payments from an unusually high number of unique devices in a short period (suggesting the QR has been mass-shared or is part of a scam), the system triggers an alert. The fraud team maintains a blacklist of known fraudulent VPA patterns — VPAs that have been associated with QR replacement scams, phishing campaigns, or unauthorized collection of funds.
QR Fraud Detection Signals
| Signal | Detection Method | Action | Severity |
|---|---|---|---|
| QR code expired | Token timestamp validation | Reject payment | N/A (legitimate) |
| Signature mismatch | HMAC-SHA256 verification | Block + alert ops | Critical |
| Geo-mismatch | Device GPS vs merchant registered addr | Step-up auth + flag | High |
| Velocity spike | >50 unique payers in 10 min on static QR | Temporarily freeze QR | High |
| VPA blacklist hit | Real-time VPA reputation DB lookup | Block payment | Critical |
| Amount anomaly | Payment amount >3x merchant's avg transaction | Step-up auth | Medium |
| Known fraud device | Device fingerprint in fraud DB | Block + escalate | Critical |
| QR code image shared | Image hash shared across multiple devices | Alert merchant + verify | Medium |
C# Implementation: QR Security Service
public class QrSecurityService
{
private readonly IConnectionMultiplexer _redis;
private readonly IDbConnection _db;
private readonly IFraudAlertPublisher _alertPublisher;
private readonly ILogger<QrSecurityService> _logger;
private const int DYNAMIC_QR_TTL_SECONDS = 45;
private const int STATIC_QR_VELOCITY_LIMIT = 50;
public async Task<DynamicQrToken> GenerateDynamicQrAsync(
long merchantId, decimal? fixedAmount = null)
{
var tokenId = Guid.NewGuid().ToString("N")[..16];
var payload = new QrPayload
{
MerchantId = merchantId,
TokenId = tokenId,
Amount = fixedAmount,
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = DateTime.UtcNow.AddSeconds(DYNAMIC_QR_TTL_SECONDS)
};
var signature = ComputeHmacSha256(
JsonSerializer.Serialize(payload),
await GetMerchantSigningKeyAsync(merchantId));
var db = _redis.GetDatabase();
var tokenKey = $"qr:token:{merchantId}:{tokenId}";
await db.StringSetAsync(tokenKey,
JsonSerializer.Serialize(payload),
TimeSpan.FromSeconds(DYNAMIC_QR_TTL_SECONDS + 10));
var scanCountKey = $"qr:scans:{merchantId}";
await db.StringSetAsync(scanCountKey, "0",
TimeSpan.FromMinutes(10));
return new DynamicQrToken
{
TokenId = tokenId,
Payload = payload,
Signature = signature,
ExpiresAt = payload.ExpiresAtUtc,
QrData = BuildUpiQrString(
await GetMerchantVpaAsync(merchantId),
await GetMerchantNameAsync(merchantId),
fixedAmount, tokenId, signature)
};
}
public async Task<QrVerificationResult> VerifyQrPaymentAsync(
QrPaymentRequest request)
{
var payload = request.DecodedPayload;
var db = _redis.GetDatabase();
if (payload.ExpiresAtUtc <= DateTime.UtcNow)
return QrVerificationResult.Fail("QR code has expired");
var tokenKey = $"qr:token:{payload.MerchantId}:{payload.TokenId}";
var stored = await db.StringGetAsync(tokenKey);
if (!stored.HasValue)
return QrVerificationResult.Fail("QR token not found or expired");
var expectedSig = ComputeHmacSha256(
JsonSerializer.Serialize(payload),
await GetMerchantSigningKeyAsync(payload.MerchantId));
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(request.Signature),
Encoding.UTF8.GetBytes(expectedSig)))
{
await _alertPublisher.PublishAsync(new QrFraudAlert
{
MerchantId = payload.MerchantId,
TokenId = payload.TokenId,
Reason = "Signature mismatch - possible QR tampering",
Severity = AlertSeverity.Critical,
DeviceId = request.DeviceId,
IpAddress = request.IpAddress
});
return QrVerificationResult.Fail("Invalid QR signature");
}
var merchantLocation = await GetMerchantLocationAsync(
payload.MerchantId);
if (merchantLocation != null && request.DeviceLatitude.HasValue)
{
var distance = CalculateDistance(
merchantLocation.Lat, merchantLocation.Lng,
request.DeviceLatitude.Value, request.DeviceLongitude.Value);
if (distance > 5.0)
{
await _alertPublisher.PublishAsync(new QrFraudAlert
{
MerchantId = payload.MerchantId,
Reason = $"Geo mismatch: {distance:F1}km from merchant",
Severity = AlertSeverity.High
});
return QrVerificationResult.RequiresStepUp(
"Location verification needed");
}
}
var scanKey = $"qr:scans:{payload.MerchantId}";
var scanCount = await db.StringIncrementAsync(scanKey);
if (scanCount == 1)
await db.KeyExpireAsync(scanKey, TimeSpan.FromMinutes(10));
if (scanCount > STATIC_QR_VELOCITY_LIMIT)
{
await _alertPublisher.PublishAsync(new QrFraudAlert
{
MerchantId = payload.MerchantId,
Reason = $"Velocity spike: {scanCount} scans in 10 min",
Severity = AlertSeverity.High
});
return QrVerificationResult.RequiresStepUp(
"Unusual scan volume detected");
}
var isBlacklisted = await IsVpaBlacklistedAsync(
await GetMerchantVpaAsync(payload.MerchantId));
if (isBlacklisted)
return QrVerificationResult.Fail("Merchant VPA under review");
return QrVerificationResult.Verified(payload);
}
private string ComputeHmacSha256(string data, string key)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
return Convert.ToBase64String(hash);
}
private double CalculateDistance(
double lat1, double lng1, double lat2, double lng2)
{
var R = 6371.0;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(lat1 * Math.PI / 180) *
Math.Cos(lat2 * Math.PI / 180) *
Math.Sin(dLng / 2) * Math.Sin(dLng / 2);
return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
}
30. Conclusion
Designing a digital payments platform like PhonePe at 500M+ user scale is one of the most challenging engineering problems in the industry. It demands expertise across distributed systems, financial protocols, security, regulatory compliance, and real-time processing.
Here's a summary of the key architectural decisions we covered:
- UPI Protocol Integration: Deep understanding of NPCI switch, VPA resolution, IMPS settlement, and message flows is essential for any payment PSP
- Strong Consistency: ACID transactions with serializable isolation for wallet operations, optimistic locking for concurrent debits, and outbox pattern for guaranteed event delivery
- Idempotency: Three-layer deduplication (Redis fast-check, MySQL unique constraints, client-side UUIDv7) ensures zero duplicate payments
- Fraud Detection: Sub-100ms risk scoring using pre-computed Redis feature stores with ML models running in-process
- Sharding: Hash-based sharding on user_id with 256 virtual nodes, monthly range partitioning on transactions, and denormalized read models for cross-shard queries
- Settlement: T+1 batch settlement with IMPS/NEFT, 3x daily reconciliation against NPCI records
- Security: PCI-DSS Level 1 compliance, HSM-based UPI PIN validation, AES-256 encryption, and certificate pinning
- Multi-Region: Active-active deployment with synchronous cross-region replication, RPO < 1 second, RTO < 30 seconds
- Cost Management: ₹1.5 crore monthly infrastructure cost offset by merchant MDR and financial product revenues
The complete C# implementation provided in this article can serve as a starting skeleton for building a production payment engine. Extend it with circuit breakers (Polly), distributed tracing (OpenTelemetry), health checks, graceful shutdown, and comprehensive unit/integration tests before deploying to production.
Remember: in payment systems, every microsecond of downtime costs money, and every bug can mean losing real money. Design for failure, test for disaster, and always have a rollback plan.