System Design Interview: Design a Payment System (Stripe/PayPal)

System Design Interview: Design a Payment System (Stripe/PayPal)

Payment system design is one of the most important system design interviews, commonly asked at Stripe, PayPal, Square, Coinbase, Shopify, and any fintech company. It tests your understanding of financial data consistency, idempotency, distributed transactions, and regulatory requirements.

Requirements Clarification

Functional Requirements

  • Accept payments from customers (card, bank transfer, digital wallets)
  • Pay out to merchants/sellers
  • Support refunds (full and partial)
  • Handle recurring billing/subscriptions
  • Multi-currency support
  • Transaction history and reporting

Non-Functional Requirements

  • Exactly-once payment processing — no double charges, no missed payments
  • High consistency — financial data must be ACID-compliant
  • Availability: 99.99% (payment downtime = direct revenue loss)
  • Scale: 10M transactions/day = ~116 TPS average, 1000 TPS peak
  • Compliance: PCI-DSS for card data, SOX for financial reporting
  • Audit trail: every state change must be logged immutably

High-Level Architecture

Client → API Gateway → Payment Service → Payment Processor (Stripe/Adyen)
                              ↓                      ↓
                        Ledger Service          Webhook Handler
                              ↓                      ↓
                     PostgreSQL (ACID)        Event Queue (Kafka)
                              ↓
                      Account Service
                      Risk/Fraud Service

Core Data Model

-- Immutable ledger: append-only, never update/delete
CREATE TABLE ledger_entries (
    id              BIGSERIAL PRIMARY KEY,
    transaction_id  UUID NOT NULL,
    account_id      UUID NOT NULL,
    entry_type      VARCHAR(10) NOT NULL,  -- DEBIT or CREDIT
    amount          BIGINT NOT NULL,        -- in cents, never floats!
    currency        CHAR(3) NOT NULL,       -- ISO 4217: USD, EUR, GBP
    balance_after   BIGINT NOT NULL,        -- denormalized for audit
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    metadata        JSONB
);
-- Never UPDATE or DELETE ledger entries — only INSERT
-- Double-entry bookkeeping: every transaction has paired DEBIT + CREDIT

CREATE TABLE transactions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,  -- client-provided
    status          VARCHAR(20) NOT NULL,  -- PENDING/PROCESSING/SUCCEEDED/FAILED/REFUNDED
    amount          BIGINT NOT NULL,
    currency        CHAR(3) NOT NULL,
    from_account    UUID NOT NULL,
    to_account      UUID NOT NULL,
    payment_method  JSONB,               -- card last4, expiry (NEVER store full PAN)
    processor_ref   VARCHAR(255),        -- external payment processor ID
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    failure_reason  TEXT
);

CREATE TABLE accounts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL,
    account_type    VARCHAR(20) NOT NULL,  -- CUSTOMER, MERCHANT, PLATFORM_FEE
    currency        CHAR(3) NOT NULL,
    -- balance is computed from ledger, but cached here for performance
    available_balance  BIGINT NOT NULL DEFAULT 0,  -- can be spent
    pending_balance    BIGINT NOT NULL DEFAULT 0,  -- holds/pending
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Idempotency: The Critical Pattern

import uuid
import redis
import json
from functools import wraps

redis_client = redis.Redis()

def idempotent_payment(func):
    """
    Decorator that ensures payment function executes exactly once.
    Client must provide Idempotency-Key header.
    """
    @wraps(func)
    def wrapper(idempotency_key: str, *args, **kwargs):
        cache_key = f"idempotency:{idempotency_key}"

        # Check if we've seen this key before
        cached = redis_client.get(cache_key)
        if cached:
            # Return cached response — don't re-execute
            return json.loads(cached)

        # Set a lock to prevent concurrent duplicate requests
        lock_key = f"idempotency_lock:{idempotency_key}"
        lock = redis_client.set(lock_key, "1", nx=True, ex=30)  # 30s lock
        if not lock:
            raise Exception("Duplicate request in flight, please retry")

        try:
            result = func(idempotency_key, *args, **kwargs)
            # Cache the result for 24 hours
            redis_client.setex(cache_key, 86400, json.dumps(result))
            return result
        finally:
            redis_client.delete(lock_key)

    return wrapper

@idempotent_payment
def charge_customer(idempotency_key: str, amount: int, currency: str,
                   payment_method_id: str, customer_id: str) -> dict:
    """
    Idempotent payment charge.
    If called twice with same idempotency_key, returns same result.
    """
    # 1. Check if transaction already exists in DB
    existing = Transaction.query.filter_by(idempotency_key=idempotency_key).first()
    if existing:
        return existing.to_dict()

    # 2. Risk check
    risk_score = risk_service.evaluate(customer_id, amount, payment_method_id)
    if risk_score > 0.8:
        raise PaymentDeclinedException("Risk threshold exceeded")

    # 3. Create transaction record in PENDING state
    txn = Transaction(
        id=uuid.uuid4(),
        idempotency_key=idempotency_key,
        status='PENDING',
        amount=amount,
        currency=currency,
        from_account=customer_id,
    )
    db.session.add(txn)
    db.session.commit()

    # 4. Call payment processor
    try:
        processor_result = stripe.charge(
            amount=amount,
            currency=currency,
            payment_method=payment_method_id,
            idempotency_key=idempotency_key,  # Forward to Stripe too!
        )
        txn.status = 'SUCCEEDED'
        txn.processor_ref = processor_result.id
    except stripe.CardError as e:
        txn.status = 'FAILED'
        txn.failure_reason = e.message

    db.session.commit()
    return txn.to_dict()

Double-Entry Bookkeeping

from decimal import Decimal
import psycopg2

def process_payment_ledger(transaction_id: str, amount_cents: int,
                           from_account_id: str, to_account_id: str,
                           currency: str, conn):
    """
    Double-entry bookkeeping: every transaction creates paired entries.
    Assets always equal liabilities — debit one account, credit another.
    All operations in single ACID transaction.
    """
    with conn.cursor() as cur:
        # Lock accounts to prevent concurrent balance modification
        cur.execute("""
            SELECT id, available_balance FROM accounts
            WHERE id = ANY(%s) ORDER BY id FOR UPDATE
        """, ([from_account_id, to_account_id],))
        accounts = {row[0]: row[1] for row in cur.fetchall()}

        from_balance = accounts[from_account_id]
        if from_balance < amount_cents:
            raise InsufficientFundsError(f"Balance {from_balance} < {amount_cents}")

        new_from_balance = from_balance - amount_cents
        new_to_balance = accounts[to_account_id] + amount_cents

        # Debit sender
        cur.execute("""
            INSERT INTO ledger_entries
            (transaction_id, account_id, entry_type, amount, currency, balance_after)
            VALUES (%s, %s, 'DEBIT', %s, %s, %s)
        """, (transaction_id, from_account_id, amount_cents, currency, new_from_balance))

        # Credit receiver
        cur.execute("""
            INSERT INTO ledger_entries
            (transaction_id, account_id, entry_type, amount, currency, balance_after)
            VALUES (%s, %s, 'CREDIT', %s, %s, %s)
        """, (transaction_id, to_account_id, amount_cents, currency, new_to_balance))

        # Update cached balances
        cur.execute("""
            UPDATE accounts SET available_balance = %s, updated_at = NOW()
            WHERE id = %s
        """, (new_from_balance, from_account_id))

        cur.execute("""
            UPDATE accounts SET available_balance = %s, updated_at = NOW()
            WHERE id = %s
        """, (new_to_balance, to_account_id))

        conn.commit()
        # Sum of all CREDIT - DEBIT across all accounts should always = 0

Handling Payment Processor Webhooks

from flask import Flask, request, jsonify
import hmac, hashlib

app = Flask(__name__)

@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    """
    Stripe notifies us asynchronously of payment outcomes.
    Must be idempotent — Stripe retries webhooks on failure.
    Verify signature to prevent spoofing.
    """
    payload = request.get_data()
    sig_header = request.headers.get('Stripe-Signature')

    # 1. Verify webhook signature
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except stripe.error.SignatureVerificationError:
        return jsonify({'error': 'Invalid signature'}), 400

    # 2. Check for duplicate webhook (Stripe delivers at-least-once)
    event_id = event['id']
    if WebhookEvent.query.filter_by(stripe_event_id=event_id).first():
        return jsonify({'status': 'already processed'}), 200

    # 3. Store event immediately (idempotency record)
    WebhookEvent.create(stripe_event_id=event_id, event_type=event['type'])

    # 4. Process event asynchronously (don't block webhook response)
    kafka_producer.send('payment_events', {
        'event_id': event_id,
        'event_type': event['type'],
        'data': event['data'],
    })

    return jsonify({'status': 'accepted'}), 200

Refund Flow

def process_refund(transaction_id: str, refund_amount: int,
                   reason: str, idempotency_key: str) -> dict:
    """
    Refunds are reverse transactions, not deletions.
    Partial refunds allowed; total refunds cannot exceed original.
    """
    original_txn = Transaction.query.get(transaction_id)
    if not original_txn or original_txn.status != 'SUCCEEDED':
        raise InvalidRefundError("Transaction not eligible for refund")

    # Check refund doesn't exceed original
    existing_refunds = sum(r.amount for r in original_txn.refunds)
    if existing_refunds + refund_amount > original_txn.amount:
        raise RefundExceedsOriginalError("Refund would exceed original amount")

    # Initiate refund with processor
    processor_refund = stripe.Refund.create(
        charge=original_txn.processor_ref,
        amount=refund_amount,
        idempotency_key=idempotency_key,
    )

    # Create reverse ledger entries (credit sender, debit receiver)
    process_payment_ledger(
        transaction_id=f"refund_{idempotency_key}",
        amount_cents=refund_amount,
        from_account_id=original_txn.to_account,   # reverse direction
        to_account_id=original_txn.from_account,
        currency=original_txn.currency,
        conn=db.connection
    )

    # Update original transaction status
    if existing_refunds + refund_amount == original_txn.amount:
        original_txn.status = 'FULLY_REFUNDED'
    else:
        original_txn.status = 'PARTIALLY_REFUNDED'

    return {'refund_id': processor_refund.id, 'amount': refund_amount}

Key Design Decisions

  • Amounts in cents (integers), never floats: Floating point arithmetic is dangerous for money. 0.1 + 0.2 = 0.30000000000000004 in IEEE 754. Use integer cents or Python Decimal.
  • Append-only ledger: Never update or delete ledger entries. This provides a perfect audit trail and enables balance reconstruction at any point in time.
  • Idempotency at every layer: Client sends idempotency key → you cache result → you forward key to Stripe → Stripe handles their side idempotently. Retry-safe end to end.
  • SELECT FOR UPDATE: Lock account rows during balance updates to prevent race conditions. At 1000 TPS this is manageable; for higher scale use optimistic locking with version numbers.
  • Synchronous processor call, async webhook: Card charge call is synchronous (user waits for response). Processor webhook is async — queue it, don’t process inline to avoid blocking retries.

Scaling Considerations

  • Read replicas: Transaction history reads go to replicas; writes go to primary
  • Horizontal sharding: Shard by account_id or user_id (not by time — hot shard problem)
  • CQRS: Separate write model (ACID PostgreSQL) from read model (denormalized for dashboards)
  • Currency conversion: Store amounts in both original currency and settlement currency; use daily FX rates from a rates service
  • Reconciliation job: Nightly job reconciles your ledger against processor statements — catches any discrepancies

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you prevent duplicate payments in a payment processing system?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use idempotency keys — a UUID that the client generates and sends with every payment request. On the server: before processing, check if a payment with this idempotency_key already exists in the database. If yes, return the original result without re-processing. If no, atomically insert a new payment record (with a UNIQUE constraint on idempotency_key) and process it. The unique constraint prevents race conditions: if two concurrent requests arrive with the same key, only one INSERT succeeds — the other gets a duplicate key error and returns the existing result. Clients should generate the idempotency key before the request and reuse it on retries. Key TTL: 24 hours is standard — after that, a new payment attempt with the same key is treated as a new payment. This is exactly how Stripe implements idempotency: every API endpoint accepts an Idempotency-Key header.”}},{“@type”:”Question”,”name”:”What is double-entry bookkeeping and why is it used in payment systems?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Double-entry bookkeeping records every financial transaction as two equal and opposite entries: a debit from one account and a credit to another. The invariant: sum of all debits = sum of all credits = 0 across the entire ledger. This provides a built-in correctness check — any inconsistency (a payment that credited a merchant without debiting a customer) violates the invariant and is immediately detectable. Implementation: never update balances directly. Instead, insert two ledger entries atomically in a database transaction. Account balances are derived by summing ledger entries: SELECT SUM(credits) – SUM(debits) WHERE account_id = X. Benefits: complete audit trail (every cent is traced), no “lost” money, easy reconciliation against bank statements. Stripe, Square, and all regulated financial institutions use this pattern.”}},{“@type”:”Question”,”name”:”What is the difference between payment authorization and capture?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Authorization: the payment processor checks that the customer has sufficient funds/credit and places a hold (reserve) on that amount. The funds are not moved yet. The merchant receives an authorization code confirming the hold. Capture: the merchant tells the processor to actually move the funds. This happens after the service is rendered — hotels authorize on check-in, capture on check-out; e-commerce sites authorize on order, capture on shipment. Why separate? (1) The customer can’t spend the reserved funds elsewhere — it’s locked. (2) The merchant can capture less than the authorized amount (e.g., a hotel that charges fewer nights than originally planned). (3) Authorization holds typically expire in 7 days — merchants must capture before expiry. In code: auth is synchronous (card network responds in <2s); capture can be async (batched nightly by merchants). If the merchant never captures, the hold is released and the customer is never charged."}}]}

🏢 Asked at: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

🏢 Asked at: Coinbase Interview Guide

🏢 Asked at: Shopify Interview Guide

🏢 Asked at: Uber Interview Guide 2026: Dispatch Systems, Geospatial Algorithms, and Marketplace Engineering

🏢 Asked at: DoorDash Interview Guide

Scroll to Top