Design a Payment System
Payment systems like Stripe enable merchants to accept payments from customers without building their own payment processing infrastructure. This is a challenging design problem because it requires handling real money—where correctness, security, and durability are non-negotiable.
This walkthrough follows the Interview Framework and focuses on what you'd actually present in a 45-60 minute interview.
Phase 1: Requirements
Functional Requirements
- Merchants should be able to initiate payment requests (charge a customer for a specific amount)
- Users should be able to pay with credit/debit cards
- Merchants should be able to view payment status updates (pending, succeeded, failed)
Keep functional requirements focused on the core payment flow. Features like saved payment methods, refunds, subscriptions, and payouts are important but can be mentioned as "below the line" to show awareness without overcomplicating your design.
Non-Functional Requirements
- Security: The system must be highly secure (PCI DSS compliant)
- Durability: No transaction data can ever be lost, even during failures
- Financial integrity: No double-charging, no lost payments despite asynchronous external networks
- Scalability: Handle 10,000+ TPS with bursty traffic (e.g., holiday sales)
Capacity Estimation
| Metric | Value |
|---|---|
| Peak TPS | 10,000 transactions/second |
| Transaction size | ~500 bytes |
| Daily transactions | ~50 million |
| Daily storage | 50M × 500B = 25 GB |
| Yearly storage | ~9 TB (before archiving) |
For a payment system, the interviewer cares more about correctness and durability than raw throughput. Mention capacity briefly, then focus on the harder problems: idempotency, consistency, and security.
Phase 2: Data Model
Core Entities
Merchant
├── id (PK)
├── name
├── api_key_hash
├── webhook_url
├── bank_account_info (encrypted)
└── created_at
PaymentIntent
├── id (PK)
├── merchant_id (FK)
├── idempotency_key (unique per merchant)
├── amount_cents
├── currency
├── status: created | processing | requires_action | succeeded | failed | canceled
├── description
├── metadata (JSONB)
├── created_at
└── updated_at
Transaction
├── id (PK)
├── payment_intent_id (FK)
├── type: charge | refund | dispute
├── amount_cents
├── status: pending | authorized | captured | succeeded | failed | pending_resolution
│ ↳ Charges: pending → authorized → captured (success) or failed
│ ↳ Refunds/Disputes: pending → succeeded or failed
├── needs_reconciliation (boolean)
├── network_reference_id
├── error_code
├── created_at
└── updated_at
PaymentIntent vs Transaction
This distinction is crucial and often causes confusion:
- PaymentIntent: Represents the merchant's intention to collect money. Owns the state machine and enforces idempotency. One PaymentIntent exists per checkout.
- Transaction: Represents actual money movement attempts. Multiple transactions can link to one PaymentIntent (retries, partial payments, refunds).
PaymentIntent (Order #1234, $50)
├── Transaction 1: Charge attempt → Failed (insufficient funds)
├── Transaction 2: Charge attempt → Captured
└── Transaction 3: Refund → Succeeded ($10 partial refund)
Phase 3: API Design
Create PaymentIntent
POST /v1/payment-intents
Headers:
Authorization: Bearer sk_live_xxx
Idempotency-Key: order_1234_checkout
Request:
{
"amount": 2499,
"currency": "usd",
"description": "Order #1234"
}
Response:
{
"id": "pi_abc123",
"status": "created",
"client_secret": "pi_abc123_secret_xyz"
}
Process Payment (Charge)
POST /v1/payment-intents/{id}/confirm
Headers:
X-Publishable-Key: pk_live_xxx
Request:
{
"payment_method": "pm_card_visa",
"client_secret": "pi_abc123_secret_xyz",
"return_url": "https://merchant.com/complete"
}
Response:
{
"id": "pi_abc123",
"status": "processing",
"next_action": null
}
If the issuer requires additional authentication (e.g., 3DS/SCA), return status: "requires_action" with a next_action payload describing the redirect or SDK flow.
Never pass raw card numbers in API requests. In a real system, card details are collected via a secure iframe (Stripe Elements) that tokenizes them into a payment_method ID. This keeps the merchant's servers out of PCI scope.
Get Payment Status
GET /v1/payment-intents/{id}
Response:
{
"id": "pi_abc123",
"status": "succeeded",
"amount": 2499,
"currency": "usd",
"transactions": [{
"id": "txn_xyz",
"type": "charge",
"status": "captured",
"amount": 2499
}]
}
Webhook Notifications
POST {merchant_webhook_url}
Headers:
Stripe-Signature: t=1234,v1=abc...
{
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_abc123",
"amount": 2499,
"status": "succeeded"
}
}
}
Phase 4: High-Level Design
Component Responsibilities
| Component | Responsibility |
|---|---|
| API Gateway | Authentication, rate limiting, request routing |
| Payment Service | PaymentIntent lifecycle, idempotency enforcement |
| Transaction Service | Card network communication, transaction state management |
| Webhook Service | Reliable delivery of events to merchants |
| Tokenization Service | Secure iframe + card tokenization to keep merchants out of PCI scope |
| CDC + Kafka | Capture database changes for event streaming and audit |
Payment Flow Walkthrough
Step 1: Create PaymentIntent
- Merchant server calls
POST /payment-intentswith amount and idempotency key - Payment Service creates record with status
created - Returns
client_secretfor frontend to complete payment
Step 2: Collect Payment Details
- Customer enters card in secure iframe (never touches merchant server)
- Card tokenized into
payment_methodID - Client receives
payment_methodtoken
Step 3: Confirm PaymentIntent
- Client SDK calls
POST /payment-intents/{id}/confirmwithclient_secret - Payment Service validates intent, runs risk checks, and asks Transaction Service to create a
pendingtransaction
Step 4: Process with Card Network
- Transaction Service sends authorization request via an acquiring bank/processor
- Card network routes the request to issuer bank for approval
- Response updates transaction status to
authorizedorfailed - If
capture_method=automatic, capture immediately and mark ascaptured
Step 5: Notify Merchant
- CDC captures database changes, publishes to Kafka
- Webhook Service consumes events
- Delivers webhook to merchant's registered URL
- Merchant triggers fulfillment (ship product, grant access)
Phase 5: Deep Dives
1. Preventing Double Charges (Idempotency)
The most dangerous failure mode in payments is charging a customer twice. This happens when:
- Network timeout occurs, merchant retries
- Customer clicks "Pay" multiple times
- Our service crashes mid-transaction
Solution: Idempotency Keys
CREATE TABLE payment_intents (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
-- ... other fields
UNIQUE(merchant_id, idempotency_key)
);
def create_payment_intent(merchant_id, idempotency_key, amount):
# Use atomic upsert to prevent race conditions
# If idempotency_key exists, return existing record
# Otherwise, insert new record
intent = db.query("""
INSERT INTO payment_intents (merchant_id, idempotency_key, amount, status)
VALUES (?, ?, ?, 'created')
ON CONFLICT (merchant_id, idempotency_key)
DO UPDATE SET updated_at = NOW() -- No-op update to return existing row
RETURNING *
""", merchant_id, idempotency_key, amount)
return intent
The ON CONFLICT clause makes this atomic—two concurrent requests with the same idempotency key will never both succeed in creating new records.
Idempotency keys should be generated by the client (merchant) and represent a specific business operation (e.g., order_1234_checkout). This way, retries for the same order reuse the same key.
2. Handling Asynchronous Payment Networks
Payment networks are fundamentally asynchronous. A timeout doesn't mean failure—the payment might still be processing. This creates three dangerous scenarios:
| Scenario | What Happened | Risk |
|---|---|---|
| Timeout | Request still processing | Double-charge if we retry |
| Lost response | Payment succeeded, response lost | Think it failed, but customer charged |
| Lost request | Request never arrived | Think it failed, actually failed |
Solution: Pending States + Reconciliation
def process_payment(payment_intent_id, payment_method):
payment_intent = db.query("""
SELECT amount_cents
FROM payment_intents
WHERE id = ?
""", payment_intent_id)
# 1. Create transaction in PENDING state BEFORE calling network
txn = db.insert("""
INSERT INTO transactions (payment_intent_id, status)
VALUES (?, 'pending')
""", payment_intent_id)
try:
# 2. Call card network with our transaction ID
result = card_network.authorize(
merchant_reference_id=txn.id,
amount=payment_intent.amount_cents
)
# 3. Update based on response
db.update("UPDATE transactions SET status = ? WHERE id = ?",
result.status, txn.id)
except Timeout:
# Don't assume failure! Mark for reconciliation
db.update("""
UPDATE transactions
SET status = 'pending_resolution', needs_reconciliation = true
WHERE id = ?
""", txn.id)
Reconciliation Worker
def reconciliation_job():
"""Runs every few minutes to resolve uncertain transactions"""
pending = db.query("""
SELECT * FROM transactions
WHERE status = 'pending_resolution'
AND created_at < NOW() - INTERVAL '5 minutes'
""")
for txn in pending:
# Query card network using our merchant reference ID (sent in original request)
# If the network doesn't support this, fall back to acquirer reconciliation files
actual_status = card_network.query_transaction(merchant_reference_id=txn.id)
if actual_status == 'not_found':
# Request never arrived—mark as failed (don't auto-retry to avoid double-charge risk)
db.update("UPDATE transactions SET status = 'failed' WHERE id = ?", txn.id)
else:
# Update with actual status from network
db.update("UPDATE transactions SET status = ? WHERE id = ?",
actual_status, txn.id)
3. Durability and Audit Trail
For a payment system, losing transaction data is catastrophic—both legally and financially. We need:
- Immutable audit log for compliance
- Event stream for real-time processing
- Reconciliation with external systems
Solution: CDC + Event Streaming
Why CDC over Application Events?
| Approach | Pros | Cons |
|---|---|---|
| Application publishes events | Simple, explicit | Can miss events if app crashes after DB write but before publish |
| CDC from database | Captures all changes, no missed events | More complex; consumers must be idempotent |
With CDC, every database change is captured from the write-ahead log (WAL), ensuring no events are lost even if the application crashes.
CDC isn't magic—consumers must handle duplicates (idempotent processing) and CDC can lag during high load. For critical paths, some teams combine CDC with a transactional outbox pattern for additional guarantees.
4. Security: Protecting Card Data
Card data is extremely sensitive. PCI DSS compliance requires strict controls.
Solution: Tokenization with Secure Iframe
Key Security Measures:
- Iframe isolation: Card form runs on our domain, merchant JavaScript can't access it
- Tokenization: Raw card numbers converted to tokens immediately
- Encryption at rest: Sensitive data (tokens, bank info) encrypted with HSM-managed keys
- API key authentication: Separate publishable (frontend) and secret (backend) keys
- Request signing: HMAC signatures prevent tampering
5. Authorization vs Capture
Payment processing has two distinct phases that some merchants need to control separately:
| Phase | What Happens | When Funds Move |
|---|---|---|
| Authorization | Bank reserves funds, validates card | No money moves yet |
| Capture | Initiates fund collection (actual bank-to-bank settlement is batched) | Settlement T+1/T+2 |
Why separate them?
- Hotels/Car Rentals: Authorize when booking, capture when guest checks out (final amount may differ)
- Marketplaces: Authorize buyer's card, capture only when seller ships
- Pre-orders: Authorize now, capture when product ships weeks later
POST /v1/payment-intents
{
"amount": 10000,
"capture_method": "manual" // Don't capture immediately
}
// Later, when ready to charge:
POST /v1/payment-intents/{id}/capture
{
"amount_to_capture": 8500 // Can capture less than authorized
}
Authorizations typically expire after 7 days. If you don't capture by then, the hold is released and you'd need to re-authorize.
6. Scaling to 10K+ TPS
Database Scaling
At 10K TPS, a single PostgreSQL instance is at its limit. Options:
| Strategy | When to Use |
|---|---|
| Read replicas | High read volume (status checks, reports) |
| Sharding by merchant_id | Write-heavy, many merchants (watch for large-merchant hotspots) |
| Caching | Frequently accessed data (merchant config) |
Kafka Partitioning
Use payment_intent_id as the partition key. Kafka hashes the key to determine the partition:
Topic: payment-events (5 partitions)
Key: payment_intent_id
pi_abc123 → hash("pi_abc123") % 5 → Partition 2
pi_xyz789 → hash("pi_xyz789") % 5 → Partition 4
All events for the same PaymentIntent go to the same partition, guaranteeing order. Different PaymentIntents can be processed in parallel across partitions.
Data Archival
At 25 GB/day, storage grows fast. Implement tiered storage:
- Hot (0-90 days): Primary database, fast queries
- Warm (90 days - 2 years): Read replicas, slower queries acceptable
- Cold (2+ years): S3/Glacier, compliance retention only
Common Pitfalls
Assuming timeout = failure — A timeout means unknown state. The payment might have succeeded, be processing, or failed. Never automatically retry without checking current state or using idempotency keys.
Storing raw card numbers — Even briefly storing unencrypted card data violates PCI DSS and creates massive liability. Always use tokenization from the start.
Synchronous webhook delivery — Don't block payment confirmation waiting for webhook delivery. Webhooks should be async with retry logic.
Single database for everything — Payment data has different access patterns: hot transactions need fast writes, audit logs need immutability, analytics needs bulk reads. Use appropriate storage for each.
Ignoring authorization expiration — Authorizations expire (typically 7 days). If your fulfillment process is slow, the auth may expire before capture, leaving you unable to charge the customer.
Interview Checklist
| Topic | Key Points to Cover |
|---|---|
| Idempotency | Idempotency keys prevent double charges on retries |
| Async handling | Pending states + reconciliation for network uncertainty |
| Security | Tokenization, iframe isolation, PCI compliance |
| Durability | CDC + Kafka for guaranteed event capture |
| Data model | PaymentIntent vs Transaction distinction |
| Auth vs Capture | Two-phase flow; authorize reserves funds, capture moves money |
| Webhooks | Async delivery with retries for merchant notifications |
| Scaling | Shard by merchant_id, partition Kafka by payment_intent_id |
Summary
| Aspect | Solution |
|---|---|
| Core abstraction | PaymentIntent (lifecycle) + Transaction (money movement) |
| Double-charge prevention | Idempotency keys with database uniqueness constraint |
| Async network handling | Pending states + reconciliation worker |
| Durability | CDC captures all DB changes to Kafka event stream |
| Security | Tokenization via secure iframe, never store raw cards |
| Merchant notifications | Webhook service consuming from Kafka |
| Scaling | Shard DB by merchant, partition Kafka by payment_intent |