system-design58 min read

How to Design Digital Payments Platform like PhonePe — A Senior+ Guide | Ayodhyya

How to Design Digital Payments Platform like PhonePe

A Senior+ Guide to Building UPI Payments, Wallet Systems, and Merchant Settlements at 500M+ Transaction Scale

By Ayodhyya · July 14, 2026 · 35 min read

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.

500M+Registered Users
10B+Monthly Transactions
99.99%Uptime SLA
<2sP99 Latency

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.

What makes this different: Unlike generic payment system articles, we dive deep into India-specific UPI protocol mechanics, NPCI switch interactions, IMPS settlement cycles, VPA resolution, and the unique challenges of operating within India's regulatory framework at massive scale.

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

RequirementTargetRationale
Availability99.99% (52 min downtime/year)Financial services demand near-zero downtime
Latency (P99)< 2 seconds end-to-endUsers expect instant payment confirmation
Throughput50,000+ TPS peakFestival seasons see 3-5x normal traffic
DurabilityZero transaction lossEvery transaction must be recoverable
ConsistencyStrong consistency for balancesDouble-spend prevention is non-negotiable
SecurityPCI-DSS Level 1 complianceMandatory 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 TypeRecord SizeDaily VolumeDaily Storage
Transactions500 bytes300M150 GB
User Profiles2 KB100K new200 MB
Session/OTP Logs100 bytes500M50 GB
Notification Logs200 bytes600M120 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

EntityKey RelationshipsSharding Strategy
users1:N bank_accounts, 1:N upi_handles, 1:1 walletsHash(user_id) → 256 shards
transactionsN:1 users (sender/receiver), M:1 merchantsHash(sender_id) + date partition
merchants1:N settlements, 1:N transactionsHash(merchant_id) → 64 shards
wallets1:1 users, 1:N wallet_transactionsSame shard as user

5. API Design

REST API Endpoints

MethodEndpointDescriptionAuth
POST/api/v1/auth/send-otpSend OTP for loginNone
POST/api/v1/auth/verify-otpVerify OTP, return JWTNone
POST/api/v1/users/registerCreate user profileJWT
GET/api/v1/users/meGet current user profileJWT
POST/api/v1/bank-accounts/linkLink bank account via UPIJWT + PIN
GET/api/v1/bank-accountsList linked accountsJWT
POST/api/v1/upi/create-vpaCreate UPI handleJWT
GET/api/v1/wallet/balanceCheck wallet balanceJWT
POST/api/v1/wallet/loadLoad money into walletJWT + UPI-PIN
POST/api/v1/payments/transferP2P money transferJWT + UPI-PIN
POST/api/v1/payments/merchantPay merchant (QR)JWT + UPI-PIN
GET/api/v1/transactionsList transaction historyJWT
GET/api/v1/transactions/{id}Transaction detail/statusJWT
POST/api/v1/payments/requestRequest money (collect)JWT
POST/api/v1/payments/{id}/approveApprove collect requestJWT + UPI-PIN
POST/api/v1/payments/{id}/rejectReject collect requestJWT

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

graph TB subgraph Client[Client Layer] MobileApp["Mobile App"] WebApp["Web App"] MerchantApp["Merchant App"] end subgraph EdgeLayer[Edge / CDN Layer] CDN["CloudFront CDN"] WAF["WAF / Rate Limiter"] APIGW["API Gateway"] end subgraph CoreServices[Core Microservices] AuthService["Auth Service"] PaymentService["Payment Service"] WalletService["Wallet Service"] MerchantService["Merchant Service"] NotificationService["Notification Service"] FraudService["Fraud Detection"] ReconciliationService["Reconciliation Service"] end subgraph UPIBridge[UPI Bridge] NPCIGateway["NPCI Gateway"] PSPSwitch["PSP Switch"] IMPS["IMPS/NEFT Settlement"] end subgraph DataLayer[Data Layer] MySQL["MySQL Cluster"] Redis["Redis Cluster"] Kafka["Kafka Cluster"] ES["Elasticsearch"] S3["S3 / Blob Storage"] end subgraph External[External Integrations] BankHDFC["HDFC Bank"] BankICICI["ICICI Bank"] BankSBI["SBI Bank"] SMSProv["SMS Provider"] EmailProv["Email Provider"] end MobileApp --> CDN WebApp --> CDN MerchantApp --> CDN CDN --> WAF --> APIGW APIGW --> AuthService APIGW --> PaymentService APIGW --> WalletService APIGW --> MerchantService PaymentService --> NPCIGateway PaymentService --> Kafka WalletService --> MySQL WalletService --> Redis MerchantService --> MySQL FraudService --> Kafka FraudService --> Redis NotificationService --> SMSProv NotificationService --> EmailProv ReconciliationService --> Kafka ReconciliationService --> MySQL NPCIGateway --> PSPSwitch PSPSwitch --> IMPS PSPSwitch --> BankHDFC PSPSwitch --> BankICICI PSPSwitch --> BankSBI PaymentService --> Redis AuthService --> Redis Kafka --> ReconciliationService Kafka --> NotificationService MySQL --> S3
Architecture Principles: Every service is independently deployable with its own database. Communication uses Kafka for async and gRPC for sync. All financial data flows through the Payment Service, which acts as the single source of truth for transaction state. The UPI Bridge abstracts NPCI interactions.

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

LayerTechnologyWhy This Choice
Mobile AppReact Native + Kotlin/Swift native modulesCross-platform with native performance for security-critical flows
API GatewayKong / EnvoyRate limiting, authentication, request transformation, SSL termination
Core ServicesC# / .NET 8 + ASP.NET CoreHigh performance, strong typing, excellent async support
Message BusApache Kafka (3-broker minimum per cluster)Durable, ordered, replayable event streaming
Primary DatabaseMySQL 8.0 with InnoDBACID compliance, mature ecosystem, strong community
Cache LayerRedis Cluster 7.xSub-millisecond reads, atomic operations, pub/sub
SearchElasticsearch 8.xFull-text search on transactions, log aggregation
Object StorageAWS S3 / Azure BlobDocuments, KYC images, settlement files, backups
Container OrchestrationKubernetes (EKS/AKS)Auto-scaling, rolling updates, service mesh support
ObservabilityPrometheus + Grafana + JaegerMetrics, 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

ComponentRoleExample
Payer PSPPayer's UPI app providerPhonePe (Yes Bank)
Payee PSPPayee's bank/appHDFC Bank / Google Pay
NPCICentral switch, routes messagesNPCI UPI Switch
Remitter BankDebitor's bankSBI, ICICI
Beneficiary BankCreditor's bankHDFC, Axis
VPAVirtual Payment Addressuser@phonepe
QR CodeStatic/Dynamic payment requestUPI QR at merchant

UPI Message Flow

sequenceDiagram participant User as Payer PhonePe participant PSP as Payer PSP participant NPCI as NPCI Switch participant Bank as Remitter Bank participant BankB as Beneficiary Bank participant Payee as Payee PSP User->>PSP: Initiate 500 payment to merchant@hdfc PSP->>PSP: Validate VPA check balance PSP->>NPCI: PAY_REQ encrypted NPCI->>NPCI: Route to Beneficiary Bank NPCI->>BankB: DEBIT_REQ BankB->>BankB: Validate account hold funds BankB-->>NPCI: DEBIT_ACK NPCI->>Bank: CREDIT_REQ Bank->>Bank: Credit beneficiary Bank-->>NPCI: CREDIT_ACK NPCI-->>PSP: PAY_RESP success NPCI-->>Payee: CREDIT_NOTIFICATION PSP-->>User: Payment Successful SMS Payee-->>Payee: Notify merchant

IMPS vs NEFT vs RTGS

FeatureIMPSNEFTRTGS
SettlementReal-time 24x7Batch (every 30 min)Real-time (business hrs)
Min Amount₹1₹1₹2 lakh
Used By UPIYes (primary)Settlement onlyNo
Latency< 5 seconds5-30 minutes30-60 seconds
ChargesNominalNominalHigher for large amounts

8. Payment Flow (P2P & P2M)

P2P Payment Flow

sequenceDiagram participant Sender as Sender App participant GW as API Gateway participant PaySvc as Payment Service participant Fraud as Fraud Engine participant Redis as Redis participant Kafka as Kafka participant DB as Transaction DB participant UPI as NPCI/UPI Bridge Sender->>GW: POST /payments/transfer GW->>GW: Rate limit + Auth check GW->>PaySvc: Forward request PaySvc->>Redis: Check idempotency key alt Duplicate Redis-->>PaySvc: Return existing txn PaySvc-->>Sender: Return txn status end PaySvc->>Fraud: Real-time risk assessment Fraud->>Redis: Check velocity + device fingerprint Fraud-->>PaySvc: Risk score 0.0 to 1.0 alt High Risk above 0.8 PaySvc-->>Sender: Transaction blocked end PaySvc->>DB: Create transaction PENDING PaySvc->>Kafka: Emit PAYMENT_INITIATED PaySvc->>UPI: Forward to NPCI UPI-->>PaySvc: UPI response success/fail/pending PaySvc->>DB: Update transaction status PaySvc->>Redis: Cache transaction status TTL 1hr PaySvc->>Kafka: Emit PAYMENT_COMPLETED or PAYMENT_FAILED PaySvc-->>Sender: Payment response

P2M (Merchant) Payment Flow via QR Code

sequenceDiagram participant Buyer as Buyer App participant Merchant as Merchant App participant PaySvc as Payment Service participant Wallet as Wallet Service participant UPI as NPCI/UPI Bridge participant Settlement as Settlement Service Buyer->>Merchant: Scan QR Code Merchant->>Buyer: Return VPA + amount Buyer->>PaySvc: Initiate payment to merchant VPA alt Wallet Payment PaySvc->>Wallet: Debit wallet balance Wallet->>Wallet: Atomic balance update Wallet-->>PaySvc: Debit confirmed PaySvc->>PaySvc: Credit merchant wallet else Bank Payment PaySvc->>UPI: Route through NPCI UPI-->>PaySvc: Bank confirmation end PaySvc->>Settlement: Queue for T+1 settlement PaySvc-->>Buyer: Payment successful

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

graph LR A[User Bank Account] -->|UPI Pull| B[Wallet Load] B --> C[Wallet Balance] C -->|Pay| D[Merchant Payment] C -->|Transfer| E[P2P Transfer] C -->|Bill| F[Bill Payment] C -->|Refund| G[Failed Txn Refund] D -->|T+1 Settlement| H[Merchant Bank Account]

10. QR Code & Merchant Payments

QR Code Types

TypeDescriptionUse Case
Static QRContains only VPA. Amount entered by payer.Small shops, street vendors
Dynamic QRContains VPA + amount + merchant ref.Retail stores, restaurants
Bharat QREMV standard, supports cards + UPILarge merchants
UPI IntentDeep link that opens UPI app directlyApp-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

graph TB subgraph Day1[Day 1 T+0] A1[Merchant Receives Payments] --> A2[Transactions Logged] A2 --> A3[End-of-Day Batch Created] end subgraph Day2[Day 2 T+1] B1[Settlement Engine Runs] --> B2[Aggregate Daily Transactions] B2 --> B3[Deduct Commission/TDR] B3 --> B4[Generate NEFT/IMPS File] B4 --> B5[Submit to NPCI/Bank] B5 --> B6[Merchant Bank Account Credited] end A3 --> B1

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

stateDiagram-v2 [*] --> INITIATED: User initiates payment INITIATED --> VALIDATING: Fraud check passed INITIATED --> FAILED: Fraud detected VALIDATING --> PENDING: Sent to NPCI VALIDATING --> FAILED: Validation error PENDING --> COMPLETED: Bank confirms PENDING --> FAILED: Bank rejects PENDING --> TIMEOUT: No response 30s TIMEOUT --> REVERSED: Auto-reversal TIMEOUT --> PENDING: Retry COMPLETED --> REFUNDED: Refund requested FAILED --> [*] COMPLETED --> [*] REVERSED --> [*] REFUNDED --> [*]

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

LayerMechanismScopeLatency
Layer 1Redis fast-check (TTL: 24h)Recent duplicates< 1ms
Layer 2MySQL unique constraint on idempotency_keyAll historical< 10ms
Layer 3Client-generated UUIDv7 (time-ordered)Deduplication at source0ms (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

graph TB subgraph SettlementCycle[T+1 Settlement Cycle] A[Transactions for Day D] --> B[Aggregation by Merchant] B --> C[Commission/TDR Calculation] C --> D[Net Amount Calculation] D --> E[NPCI Settlement File] E --> F[IMPS/NEFT Transfer] F --> G[Merchant Bank Credit] G --> H[Settlement Confirmation] H --> I[Update Merchant Wallet] end subgraph Reconciliation[Reconciliation 3x Daily] R1[NPCI File Download] --> R2[Parse and Compare] R2 --> R3[Match Transactions] R3 --> R4[Flag Mismatches] R4 --> R5[Auto-Resolve Balance Check] R4 --> R6[Manual Queue Amount Mismatch] R5 --> R7[Reconciliation Report] R6 --> R7 end

Reconciliation States

Our StatusNPCI StatusBank StatusResolution
COMPLETEDSUCCESSCREDITEDMatched - OK
COMPLETEDSUCCESSPENDINGWait for bank processing
PENDINGSUCCESSCREDITEDUpdate our status
PENDINGFAILEDDEBITEDReverse + alert ops
COMPLETEDFAILEDN/ARefund to user
FAILEDSUCCESSCREDITEDManual investigation
COMPLETEDN/AN/AReconcile 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.

graph LR A[Payment Request] --> B{Feature Extraction} B --> C[Velocity Check] B --> D[Device Fingerprint] B --> E[Amount Pattern] B --> F[Geolocation] C --> G{Risk Score Engine} D --> G E --> G F --> G G -->|Score below 0.3| H[APPROVE] G -->|0.3 to 0.7| I[STEP-UP AUTH] G -->|above 0.7| J[BLOCK ALERT] I --> K[OTP / Biometric] K --> H K --> J

Risk Signals

SignalWeightDetection
Transaction velocityHigh>10 txns in 1 hour from same device
Device fingerprint changeMediumNew device / jailbroken / rooted
Amount anomalyMedium5x above user's 30-day average
Geo-velocityHighImpossible travel (2 cities in 10 min)
New payeeLowFirst-time payment to this VPA
Time-of-dayLowTransaction at unusual hour (3 AM)
IP reputationHighKnown 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 LevelRequirementsWallet LimitFeatures
Minimum KYCAadhaar number + OTP₹10,000/monthBasic UPI + limited wallet
Medium KYCAadhaar eKYC (biometric)₹1,00,000/yearFull wallet, bill payments
Full KYCAadhaar + PAN + Video KYC₹2,00,000 balanceAll features, merchant payments
Compliance Note: All KYC documents must be encrypted at rest using AES-256 and in transit using TLS 1.3. PAN and Aadhaar numbers are tokenized and never stored in plaintext. Audit logs must be retained for 8 years per RBI guidelines.

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

graph TB A[Kafka: payment.events] --> B[Notification Consumer] B --> C{Notification Router} C -->|Priority CRITICAL| D[SMS Gateway] C -->|Priority HIGH| E[Push Notification] C -->|Priority NORMAL| F[Email] C -->|Priority LOW| G[In-App Only] D --> H[Twilio / MSG91] E --> I[FCM / APNs] F --> J[SES / SendGrid] G --> K[WebSocket Hub] H --> L[User Phone] I --> M[User Device] J --> N[User Email] K --> O[App Notification Center]

Notification Priorities

EventSMSPushEmailIn-App
Debit successfulYesYesNoYes
Credit receivedYesYesNoYes
Payment failedNoYesNoYes
Fraud alertYesYesYesYes
Settlement creditedNoYesYesYes
OTPYesNoNoNo

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

graph LR A[User] -->|Select Biller| B[Bill Fetch API] B -->|Query BBPS| C[BBPS Gateway] C -->|Return Outstanding| D[Display Bill] D -->|User Confirms| E[Payment Service] E -->|UPI/Wallet| F[NPCI] F -->|Success| G[Biller Updated] G -->|Confirmation| H[User Notified]

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

ProductPartnerSettlementRegulatory
Mutual Funds (SIP/Lumpsum)AMCs via BSE StART+1 via RTASEBI registered
Digital GoldSafeGold / AugmontInstantNo specific license
Term InsuranceInsurance partnersOne-time / annualIRDAI regulated
Health InsuranceInsurance partnersAnnual premiumIRDAI regulated
NPSNSDL / KFintechAs per NPS cyclesPFRDA 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

graph TB subgraph AppLayer[Application Layer] App1[Payment Service] App2[Wallet Service] end subgraph ShardRouter[Consistent Hashing Router] Router[Hash Ring 256 virtual nodes] end subgraph Shards[MySQL Shard Cluster] S1[Shard 1 user 0-63M] S2[Shard 2 user 64-127M] S3[Shard 3 user 128-191M] S4[Shard 4 user 192M+] end subgraph Replicas[Read Replicas] R1[Replica per Shard 3 read replicas] end subgraph Coord[Coordination] ConfigDB[Config Service Shard Mapping] end App1 --> Router App2 --> Router Router --> S1 Router --> S2 Router --> S3 Router --> S4 S1 --> R1 S2 --> R1 S3 --> R1 S4 --> R1 ConfigDB --> Router

Sharding Rules

TableShard KeyStrategyPartitions
usersuser_idConsistent hash 256 virtual nodes to 64 physical shardsNone (hot data)
transactionssender_idSame shard as user + monthly range partitionMonthly, archived after 2 years
walletsuser_idCo-located with user (same shard)None
merchantsmerchant_idConsistent hash to 64 shardsNone
settlementsmerchant_idSame shard as merchant + date partitionDaily, 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 LevelTechnologyTTLUse Cases
L1: In-ProcessMemoryCache (C#)30 secondsUser profile, VPA resolution
L2: DistributedRedis Cluster5-60 minutesBalances, session, rate limits
L3: CDN EdgeCloudFront1-24 hoursStatic 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

LayerMechanismStandard
TransportTLS 1.3 with certificate pinningPCI-DSS 4.0
ApplicationJWT + OAuth 2.0 + UPI PINOAuth 2.1
Data at RestAES-256-GCM encryptionPCI-DSS, RBI
Key ManagementAWS KMS / HSM (FIPS 140-2 Level 3)PCI-DSS
TokenizationCard numbers to tokens (never stored)PCI-DSS
API SecurityHMAC-SHA256 request signingIndustry best practice
Device SecurityDevice binding, jailbreak detectionRBI guidelines
Critical: UPI PIN is never stored anywhere — not even encrypted. It's validated through a Hardware Security Module (HSM) at the bank's side. The app sends the PIN encrypted with the bank's public key, and the bank returns only a validation result. This is mandated by NPCI's UPI security framework.

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

graph TB subgraph Region1[Region Mumbai Primary] A1[API Gateway] --> A2[Payment Service] A2 --> A3[MySQL Primary] A3 --> A4[MySQL Replica] A2 --> A5[Redis Cluster] A2 --> A6[Kafka Cluster] end subgraph Region2[Region Delhi Secondary] B1[API Gateway] --> B2[Payment Service] B2 --> B3[MySQL Primary] B3 --> B4[MySQL Replica] B2 --> B5[Redis Cluster] B2 --> B6[Kafka Cluster] end subgraph GlobalLayer[Global Layer] GSLB[Global Server LB] NPCI[NPCI UPI Switch] Cross[Cross-Region DB Replication] end GSLB --> Region1 GSLB --> Region2 NPCI --> Region1 A3 --> Cross B3 --> Cross

Disaster Recovery Targets

MetricTargetMechanism
RPO (Recovery Point)< 1 secondSynchronous cross-region DB replication
RTO (Recovery Time)< 30 secondsAutomated failover with health checks
Availability99.99%Active-active + automatic routing
Data Durability99.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)

ComponentConfigurationMonthly 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 Cluster256GB cluster, r6g.xlarge x 16₹8,00,000
Kafka50-broker cluster, m5.2xlarge₹12,00,000
Network / CDN50TB/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 / ComplianceHSM, PCI audit, WAF₹3,00,000
Total Monthly₹1,56,00,000
Revenue Model: UPI P2P is free. Revenue comes from merchant MDR (0.5-1%), bill payment commissions (1-3%), financial product referrals (2-5% AUM), and premium subscriptions. At 300M daily txns with 20% merchant payments, monthly MDR revenue exceeds ₹450 crore.

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?
This is called a tentative transaction. The NPCI responds with a PENDING status (e.g., "account blocked" or "bank timeout"). We store the UPI RRN and schedule a status inquiry polling job: query NPCI every 30 seconds for up to 5 minutes. If the bank confirms success, we update status to COMPLETED. If it fails after timeout, we auto-reverse. The user sees "Payment Processing" and gets notified when it resolves.
Q2: How do you prevent double-spending when the same wallet balance is used for two concurrent transactions?
We use optimistic locking with version numbers on the wallet row. Each debit decrements the balance and increments the version. If two transactions try to debit simultaneously, one will fail the WHERE clause (version mismatch) and retry. For very high-contention scenarios, we use SELECT ... FOR UPDATE (pessimistic lock) with a 500ms timeout. Redis distributed locks provide an additional fast-path guard.
Q3: Design the idempotency mechanism for UPI payments end-to-end.
Three layers: (1) Client generates UUIDv7 as idempotency_key (time-ordered, unique). (2) Redis SETNX with 24h TTL — fast duplicate rejection. (3) MySQL UNIQUE constraint on idempotency_key — guaranteed dedup. If a request arrives while the previous one is still processing, we return the in-progress transaction's status. For NPCI, we generate a unique NPCI Transaction ID and track it — NPCI itself deduplicates by this ID within a 24h window.
Q4: How would you design the settlement system for 10 million merchants?
Each merchant has a settlement cycle (T+0, T+1, T+2). A Settlement Aggregator Service runs as a batch job: (1) Aggregate all completed transactions per merchant for the settlement date. (2) Calculate TDR/commission per transaction. (3) Compute net settlement amount. (4) Generate bulk NEFT/RTGS file. (5) Submit to NPCI/bank via API. (6) Track credit confirmation. For T+0 settlements, we use IMPS for real-time credits. Sharding by merchant_id ensures parallel processing.
Q5: How do you handle a network partition where the user's device disconnects after debit but before receiving confirmation?
The payment is already initiated with NPCI — the debit may have occurred. We never reverse without checking. The app retries the status check using the transaction_id on reconnect. The backend queries NPCI status inquiry API. If confirmed, we send the result. If truly failed, we initiate reversal. We also send an SMS notification (which doesn't depend on app connectivity) so the user knows their bank status.
Q6: Explain how you'd shard the transaction database for 10 billion monthly transactions.
We use hash-based sharding on sender_id with 256 virtual nodes mapped to 64 physical MySQL shards. Each shard is further partitioned by month (range partitioning on created_at). This gives us ~150M transactions per shard per month. Historical partitions (2+ years old) are archived to cold storage (S3/Parquet) for analytics. Cross-shard queries use a denormalized Elasticsearch index that stores both sender and receiver copies.
Q7: How do you implement real-time fraud detection without adding latency to payment processing?
We use a pre-computed feature store in Redis. Features like "txns in last hour", "average amount over 30 days", "known device list" are maintained incrementally as transactions occur. During payment, feature lookup takes < 5ms from Redis. The ML model runs in a pre-compiled ONNX runtime within the payment service (no network call). Model inference: ~15ms. Total fraud check: ~20ms. Features are updated asynchronously via Kafka consumers after each transaction.
Q8: Design the collect request (pull payment) flow where a merchant requests money from a customer.
Merchant enters customer's VPA and amount. Our system creates a collect request with 5-minute expiry. The request goes to NPCI, which pushes a notification to the customer's UPI app. Customer sees the request, reviews details, and approves with UPI PIN. The approval triggers the standard debit flow. We implement this via webhook callbacks from NPCI — when the customer approves/rejects, NPCI notifies our PSP endpoint. We also implement a polling fallback for reliability.
Q9: How would you handle a festival day when traffic is 5x normal (Diwali, New Year)?
Three strategies: (1) Capacity pre-scaling — auto-scaling triggers 2 hours before predicted peak using historical + ML forecasts. (2) Graceful degradation — non-critical services (analytics, recommendations) shed load; payment processing always gets priority. (3) Queue-based backpressure — when TPS exceeds processing capacity, requests queue in Kafka with priority (merchant payments > P2P > bill payments). Users see "Your payment is in queue" instead of failure. We also negotiate higher NPCI throughput limits in advance.
Q10: Design the notification system to handle 30 billion notifications per month.
Use a priority-based fan-out system. Kafka consumers read from payment events topic. Notifications are routed by priority: CRITICAL (fraud alerts) go immediately via SMS + push. HIGH (debit/credit) go via push + SMS. NORMAL (settlements) go via email + push. LOW (promotions) go via in-app only. Each channel has its own rate limiter (SMS: 100/sec per provider, push: 10K/sec). We use provider failover: if MSG91 fails, fall back to Gupshup. Templates are cached in Redis. SMS bodies are pre-rendered for common scenarios.
Q11: How do you ensure data consistency when the wallet service and payment service need to update atomically across services?
We use the Saga pattern with compensating transactions. Step 1: Payment Service creates transaction record and reserves wallet amount (locked_amount field). Step 2: On NPCI success, Payment Service confirms the debit via Wallet Service. Step 3: If NPCI fails, Payment Service sends a compensation event to unlock the reserved amount. The outbox pattern ensures exactly-once event delivery. Each saga step is idempotent. The wallet's locked_amount field ensures the user can't spend reserved funds twice.
Q12: What happens when NPCI is down and cannot process payments?
When NPCI is unreachable, we queue the payment in Kafka with a dead-letter queue for retries. The user sees "Payment queued, will be processed shortly." Our payment reconciliation service monitors NPCI availability. When it comes back, queued payments are retried in FIFO order with the same idempotency key. For wallet-to-wallet payments within our platform, we can process them independently (no NPCI needed). We also implement a circuit breaker pattern — after 5 consecutive NPCI failures, we stop sending requests for 60 seconds to avoid overwhelming the already-struggling NPCI switch.

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}");
        }
    }
}
Implementation Highlights: This 300+ line implementation covers the complete payment lifecycle: idempotency checking (Redis + MySQL dual layer), wallet balance management with optimistic locking, fraud detection with 6+ rule signals, ACID-compliant transaction processing using the outbox pattern, NPCI gateway integration with retry logic, VPA resolution with caching, transaction state management, refund processing, and notification fan-out. In production, you'd add circuit breakers (Polly), distributed tracing (OpenTelemetry), and metrics collection (Prometheus).

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

sequenceDiagram participant User as Payer Device participant LiteWallet as UPI Lite On-Device participant PSP as PSP Server participant NPCI as NPCI Switch participant Payee as Payee Device/Bank User->>LiteWallet: Initiate ₹200 payment LiteWallet->>LiteWallet: Verify local balance ≥ 200 LiteWallet->>LiteWallet: Sign txn with hardware key LiteWallet->>PSP: Submit signed UPI Lite txn (when online) PSP->>NPCI: Forward UPI Lite settlement batch NPCI->>Payee: Credit notification to payee NPCI-->>PSP: Batch settlement confirmation PSP-->>User: Payment confirmed Note over User,Payee: If offline: txn queued locally, synced when online

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

FeatureStandard UPIUPI LiteOffline UPI Lite
Max Transaction₹1,00,000₹500₹500
UPI PIN RequiredYesNoNo
Bank Round-TripYes (real-time)No (local deduction)No
Confirmation Latency1-3 seconds< 1 secondUntil online sync
Connectivity RequiredMandatoryFor load onlyNot required
Balance StorageBank serverDevice TEE + NPCI ledgerDevice TEE only
SettlementReal-time IMPSBatch (T+1)Batch (T+1)
ReconciliationPer transactionBatch reconciliationDeferred 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;
    }
}
Architectural Insight: UPI Lite fundamentally shifts the consistency model from strong (standard UPI) to eventual. The PSP must gracefully handle discrepancies between device balance and server balance, using reconciliation windows and dispute resolution flows. This is a classic CAP theorem trade-off: availability (instant offline payments) over immediate consistency.

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

graph TB subgraph SenderSide[Sender Country - India] A[Payment Request] --> B[AML Screening - RBI FMR] B --> C[FEMA Compliance Check] C --> D[Sender Bank Debit] end subgraph FXLayer[Forex & Routing] E[Real-time Forex Engine] --> F[Rate Lock & Quote] F --> G[NIPL Cross-Border Gateway] end subgraph ReceiverSide[Receiver Country] H[Foreign Payment Switch] I[Receiver Bank Credit] J[Local Compliance - MAS/MAS/ECB] end D --> E G --> H H --> I J --> I

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

StepDomestic UPICross-Border UPIAdditional Latency
1. InitiationVPA resolutionVPA + IBAN/swift code resolution+50ms
2. ComplianceInternal fraud checkFEMA + AML + foreign regulation+150ms
3. ForexN/A (INR)Real-time rate quote + lock+100ms
4. DebitDomestic bank debitDomestic bank debit+0ms
5. RoutingNPCI domestic switchNIPL cross-border gateway+200ms
6. SettlementIMPS (seconds)SWIFT gpi / bilateral netting+2-24 hours
7. CreditInstant creditForeign bank credit (T+0 to T+2)Variable
Total E2E1-3 seconds3-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 });
    }
}
Regulatory Note: Cross-border payment volumes from India are capped per person under the RBI's LRS scheme ($250,000/year). The platform must track cumulative outflows per user in real-time, handle TCS (Tax Collected at Source) for amounts above ₹7 lakh, and generate annual FBTR (Foreign Bounce Transaction Report) filings for the RBI.

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

graph TB subgraph MerchantSide[Merchant Side] M1[POS Terminal / App] -->|Generate QR| M2[QR Token Service] M2 -->|Sign with merchant key| M3[Dynamic QR Image] M2 -->|Store in Redis| M4[token:merchant_id:token_id] end subgraph CustomerSide[Customer Side] C1[Customer Scans QR] -->|Decode payload| C2[Parse token + signature] C2 -->|Submit payment| C3[PSP Payment Service] end subgraph VerificationLayer[Server-Side Verification] V1[Token Validation] --> V2{Token Expired?} V2 -->|Yes| V3[Reject - Expired QR] V2 -->|No| V4{Signature Valid?} V4 -->|No| V5[Reject - Tampered QR] V4 -->|Yes| V6{Amount Matches?} V6 -->|No| V7[Warn - Amount Mismatch] V6 -->|Yes| V8[Process Payment] end C3 --> V1

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

SignalDetection MethodActionSeverity
QR code expiredToken timestamp validationReject paymentN/A (legitimate)
Signature mismatchHMAC-SHA256 verificationBlock + alert opsCritical
Geo-mismatchDevice GPS vs merchant registered addrStep-up auth + flagHigh
Velocity spike>50 unique payers in 10 min on static QRTemporarily freeze QRHigh
VPA blacklist hitReal-time VPA reputation DB lookupBlock paymentCritical
Amount anomalyPayment amount >3x merchant's avg transactionStep-up authMedium
Known fraud deviceDevice fingerprint in fraud DBBlock + escalateCritical
QR code image sharedImage hash shared across multiple devicesAlert merchant + verifyMedium

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));
    }
}
Best Practice: Always use dynamic QR codes for merchant payments and enforce token expiry windows of 30-60 seconds. Static QR codes without time-bound tokens are inherently vulnerable to sticker overlay attacks. For high-value merchant payments, implement mandatory geo-verification as an additional security layer.

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
Key Takeaway for Interviews: When designing payment systems, always emphasize correctness over performance. A payment that takes 5 seconds but is always correct is better than a 200ms payment that occasionally loses money. Discuss trade-offs between consistency and availability (CAP theorem), explain your idempotency strategy in detail, and be ready to dive into the UPI protocol specifics — interviewers at companies like PhonePe, Razorpay, and PayU expect this depth.

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.

© 2026 Ayodhyya. All rights reserved.

Building systems that scale. One architecture at a time.