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