Key Takeaways

What you need to nail in the interview

  • 💳
    Never touch raw card data. Use Stripe Elements (iframe) + PaymentIntent. Your server only handles the client_secret — full PCI scope avoidance.
  • 🔁
    Idempotency key = duplicate-payment defense. Pass order_id as the key to Stripe; back it with a UNIQUE DB constraint as a second layer.
  • 🪝
    Webhook over polling. Stripe pushes status changes in near real-time. Polling wastes API quota and adds latency — use webhooks as the primary signal, verify the signature on every event.
  • 🛟
    Reconciliation is your safety net. Webhooks can be missed. A periodic job reads orders stuck in PENDING and calls Stripe directly — CDC → Kafka → S3 gives you the full audit trail to reprocess anything.
  • No message queue on the critical path. The user is waiting for the client_secret synchronously. Async queues belong only after the webhook arrives — not between the user and Stripe.

1. Requirements

Interview prompt: "Design the payment system for Shopify — a platform where thousands of merchants sell products to millions of buyers using credit cards."

Functional

  • Buyer pays for a product on a merchant's Shopify storefront with a credit card.
  • Merchant receives funds automatically after a successful payment, net of Shopify's platform fee.
  • Merchant can view real-time payment status for any order.
  • Buyer can request a refund; merchant can issue full or partial refunds.
  • System sends a payment confirmation notification to the buyer after checkout.

Non-Functional

  • High availability — checkout downtime is lost revenue for every merchant on the platform.
  • Strong consistency — a payment must never be marked paid unless money actually moved.
  • Low latency — the critical path (buyer submits card → sees confirmation) must complete in under 3 seconds.
  • No duplicate charges — network retries and client crashes must never result in a double charge.
  • No data loss — every payment event must be durably captured even under partial failure.
  • Multi-tenancy — thousands of merchants share the platform; one merchant's traffic spike must not degrade others.
  • Security & compliance — PCI-DSS scope minimization, fraud prevention, GDPR for buyer card data.

Scale Assumptions

  • ~500K merchants, ~50M buyers, peak ~10K transactions/second (e.g. Black Friday)
  • Average order value ~$80 USD; mix of one-time and subscription payments
  • Global — payments in 100+ currencies, multiple payment processors per region
Interview tip: Shopify-specific constraints that distinguish this from a generic payment system: multi-tenancy (one platform, many merchants), Stripe Connect for automatic merchant payouts, and scale requirements that force you to think about tenant isolation and Black Friday spikes.

2. Core Entities

🏪

Merchant

Sells products; receives cleared funds via Stripe Connect.

👤

User

Browses products, initiates checkout, enters card details.

💳

Payment / Order

Central record tying user, product, merchant, and payment state together.

📦

Product

Owned by a merchant; has a price that must be verified at checkout time.

3. API Design

REST Endpoints
GET  /products/{id}/price
     → { price, currency, merchant_id }

POST /payment_intent
     Body: { product_id, user_id }
     → { payment_intent_id, client_secret, amount, currency }

GET  /payment/{id}/status
     → { status: PENDING | SUCCESS | FAILED | REFUNDED }

The /payment_intent endpoint (not /payment/submit) matches Stripe's actual API surface. The client_secret returned is what the browser passes to Stripe Elements to collect card details — your server never touches raw card numbers.

Scope boundary: The Stripe Elements confirmation step (/v1/payment_intents/:id/confirm) is handled entirely by Stripe — you do not need to design that endpoint.

4. High-Level Architecture

High-Level Architecture diagram — Payment System

Why SQL for payments? Orders require ACID transactions — you need to atomically create a pending order and record the Stripe intent ID. DynamoDB is fine for product catalog reads (high throughput, simple key access), but relational integrity matters for financial records.

Why Redis? GET /payment/{id}/status is called frequently while a user waits. Cache the status after Stripe confirms; invalidate on webhook receipt.

5. Checkout Flow (Step by Step)

  1. Authenticate the user

    Verify JWT / session cookie. Optionally enforce MFA or passkeys for high-value transactions.

  2. Load cart & verify product

    Fetch product price from the Product Service. Verify the merchant is active via Stripe Connect. Lock in the price — never trust the price sent from the client.

  3. Create a pending order in DB

    Insert an order row with status = PENDING before calling Stripe. This is your paper trail.

  4. Create a Stripe PaymentIntent

    Call stripe.paymentIntents.create() with an idempotency key (e.g., order_id). Stripe returns a client_secret. Update the order row with the payment_intent_id.

  5. Return client_secret to the browser

    The browser passes it to Stripe Elements (an iframe). The user enters their card in Stripe's domain — your server never sees raw card data. This keeps you out of PCI scope.

  6. Stripe sends a signed webhook

    On charge success/failure, Stripe POSTs a payment_intent.succeeded (or failed) event to your webhook endpoint.

  7. Verify Stripe's signature

    Use stripe.webhooks.constructEvent(payload, sig, webhookSecret). Reject any event without a valid signature.

  8. Mark order paid & fulfill

    Update status = SUCCESS in the DB. Trigger fulfillment (ship goods, grant access). Clear the funds to the merchant via Stripe Connect. Send a confirmation notification to the user.

Why no message queue on the critical path? The user is waiting synchronously for the client_secret. Introducing a queue there adds latency and failure modes with no benefit — the Stripe call itself is the synchronous bottleneck. Async processing belongs after the webhook arrives.

6. Data Model

Products (DynamoDB)

product_id      PK
merchant_id     GSI (shard key for merchant queries)
name
price
currency
status          ACTIVE | INACTIVE

Orders / Payments (PostgreSQL)

order_id            PK  (UUID)
user_id             FK, indexed
product_id          FK
merchant_id         indexed
payment_intent_id   UNIQUE (Stripe's ID)
amount
currency
status              PENDING | SUCCESS | FAILED | REFUNDED
idempotency_key     UNIQUE
created_at
updated_at

The UNIQUE constraint on payment_intent_id and idempotency_key is your database-level guard against duplicate inserts even if application logic has a bug.

7. Webhook vs. Polling

Polling

Your server repeatedly calls Stripe's GET /payment_intents/{id} until the status changes.

Pros
  • Simple to implement
  • No public endpoint needed
Cons
  • Wasted API calls
  • Higher latency to detect completion
  • Rate limit risk at scale

Best practice: Use webhooks as the primary mechanism and a reconciliation job (see below) as the safety net. Never rely solely on polling in production.

8. Preventing Duplicate Payments

Duplicates happen when: a user double-clicks, a network retry fires, or your server retries a failed Stripe call. Three layers of defense:

1

Stripe Idempotency Key

Pass idempotency_key: order_id on every paymentIntents.create() call. Stripe deduplicates on its side — the second call with the same key returns the original response.

2

Database Unique Constraint

The UNIQUE(idempotency_key) column in your Orders table means a duplicate insert will throw a DB error before you ever reach Stripe.

3

Idempotent Webhook Handler

Stripe retries webhooks on 5xx. Your handler must check whether the order is already SUCCESS before updating. Process the event; skip silently if already applied.

9. Preventing Data Loss — Reconciliation

Webhooks can be missed: your server was down, the network timed out, or Stripe's retry window expired. You need a safety net.

CDC → Event Stream → S3 → Reconciliation

Orders DB
CDC
Kafka / Kinesis
S3 (archive)
Recon Job

Change Data Capture (CDC) — tools like Debezium capture every row change in your Orders table and publish it to a stream. Every state transition is durably logged.

Reconciliation job — runs periodically (e.g., every 15 minutes). For every order stuck in PENDING beyond a threshold, it calls GET /payment_intents/{id} on Stripe and re-applies the true status. This closes any gaps caused by missed webhooks.

Why S3? The event stream archive in S3 gives you an immutable audit log you can replay for compliance, debugging, or disaster recovery — independently of your live database.

10. Security Hardening

Verify the user

JWT / session cookie on every request. Optionally MFA or passkeys for high-value carts.

Verify the merchant

Check merchant status via Stripe Connect before creating a PaymentIntent. Never charge a user for a suspended merchant.

Verify the product price server-side

Fetch price from your DB; never trust the amount sent from the client. A client could send amount: 1.

Verify the Stripe webhook signature

Use stripe.webhooks.constructEvent() with your webhook signing secret. Reject any event that doesn't match — this prevents attackers from faking a payment_intent.succeeded event.

Never handle raw card data

Stripe Elements (iframe) collects card details entirely on Stripe's domain. Your servers are never in the PCI data flow.

Use HTTPS everywhere

Stripe requires it. Use TLS 1.2+ and HSTS headers on your webhook endpoint.

11. Key Deep Dives

Interviewers always follow up on these topics. Here's what a senior engineer actually says — not just "use idempotency," but the full reasoning behind each decision.

Why Stripe Connect over direct charges?

With Direct Charges your platform account receives every charge and must manually transfer funds to each merchant — you own the KYC compliance, payout scheduling, and dispute liability for all of them. Stripe Connect eliminates that entirely.

  • Each merchant becomes a "connected account" with their own Stripe identity, payout schedule, and dispute handling — Stripe manages their KYC
  • Pass transfer_data.destination: merchant_stripe_id and application_fee_amount on the PaymentIntent — Stripe splits and routes funds automatically
  • Suspended or unverified merchants are blocked by Stripe before a charge is created, not by your code
  • Platform liability shifts to each connected account for their own transactions, dramatically reducing your regulatory surface

What if Stripe's API times out mid-request?

A timeout means you don't know if Stripe received the request. The dangerous mistake is creating a new PaymentIntent on retry — the user gets double-charged. The safe pattern has two parts: a pre-flight check and an idempotency key.

  • Before creating an intent, check whether the order already has a payment_intent_id stored in DB — if so, return it directly without calling Stripe again
  • Pass the same idempotency_key (your order_id) on every retry call; Stripe deduplicates server-side and returns the original response
  • If the intent ID is lost in a crash, query Stripe: GET /v1/payment_intents?metadata[order_id]=xxx to recover it
  • The DB UNIQUE(payment_intent_id) constraint is the final safety net — a duplicate insert fails at the database level even if application logic has a bug

What happens if the webhook handler crashes mid-update?

Stripe retries any non-2xx response with exponential backoff for up to 72 hours — treat this as a feature, not a problem. Your handler must be written to survive re-delivery safely.

  • Verify signature first — reject anything without a valid stripe-signature header before touching the DB
  • Check current status — if the order is already SUCCESS, return 200 immediately and skip all processing
  • Use the outbox pattern — write the fulfillment event to an outbox table in the same DB transaction as the status update; a relay process publishes it to SQS/SNS afterward
  • Never publish to a queue before the DB transaction commits — a crash between the two leaves a fulfilled notification with no matching order update

What if your DB is down when a webhook arrives?

Return 5xx. Stripe will retry. This is the correct answer — fighting a DB outage by buffering the webhook elsewhere adds complexity without benefit, since Stripe's retry window is already your buffer.

  • Stripe's 72-hour retry window with exponential backoff covers virtually all realistic DB recovery windows
  • For outages that outlast retries, the reconciliation job fills the gap — it queries Stripe directly for all orders stuck in PENDING and re-applies their true status
  • CDC archives every order row change to S3; if both the DB outage and Stripe's retry window overlap, replay the event stream from S3 to reconstruct state
  • Alert on elevated webhook 5xx rates — it's a leading indicator of DB health that fires before users notice checkout failures

How do you scale the Payment Service?

Payment services are naturally stateless — every request carries enough context to process independently. Scale horizontally behind a load balancer; the idempotency key makes concurrent requests from multiple instances safe at the application layer.

  • Write path: primary PostgreSQL with synchronous replication; PgBouncer in front for connection pooling across many short-lived service instances
  • Read path: route GET /payment/{id}/status to read replicas; cache terminal statuses (SUCCESS, FAILED) in Redis since they never change
  • DB partitioning: at very high volume, partition Orders by user_id hash; time-based partitioning helps prune old rows for compliance archival
  • Stripe rate limits: account for them during flash sales — use a token-bucket limiter in front of PaymentIntent creation rather than hammering Stripe directly

How do you handle refunds and chargebacks?

Refunds follow the exact same webhook pattern as payments. Chargebacks (disputes) are different — they're initiated by the cardholder's bank and require evidence submission within a short window.

  • Refund: call stripe.refunds.create({ payment_intent: id, amount: partial_or_full }) with an idempotency key; listen for the charge.refunded webhook; update order to REFUNDED; store refund_id with a UNIQUE constraint
  • Partial refunds: track amount_refunded alongside amount in the Orders table — Stripe supports multiple partial refunds per PaymentIntent
  • Dispute (charge.dispute.created webhook): immediately freeze fulfillment and access; submit evidence to Stripe within their window (typically 7 days)
  • Stripe Radar + 3DS2 authentication reduce dispute rates significantly — with 3DS2, liability shifts from you to the card network on authenticated transactions

How do you prevent fraud?

Three independent layers: Stripe's ML-based scoring on every charge, strong authentication for high-risk transactions, and your own velocity controls at the API level. Each layer catches what the others miss.

  • Stripe Radar: rule-based + ML fraud scoring built into every charge; customize rules to block patterns like >5 failed attempts per hour from the same IP or device fingerprint
  • 3DS2 (Strong Customer Authentication): Stripe triggers this automatically for high-risk signals; the user authenticates with their bank directly and liability shifts to the card network
  • Velocity checks: rate-limit PaymentIntent creation by user_id and IP; after 3 consecutive failures lock the account and require email re-verification
  • Server-side price validation: always re-fetch price from your DB at checkout — a tampered client request sending amount: 1 is a classic attack vector

How does the notification service work?

Notifications are fully decoupled from the payment path — they run after the webhook confirms payment and never block the critical transaction. The outbox pattern keeps them consistent without distributed transactions.

  • Webhook handler commits order status + writes a notification_events row in the same DB transaction; a relay process reads new rows and publishes to an SNS topic for fan-out
  • Separate SQS queues subscribe for each channel: email (SES / SendGrid), push (Firebase / APNs), merchant webhook — each scales and retries independently
  • Workers consume with a visibility timeout; on failure the message re-appears automatically; after N retries it routes to a Dead Letter Queue for manual inspection
  • Each worker checks a notification_sent flag before sending to stay idempotent — Stripe may retry the webhook, which would re-trigger the relay without this guard