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_idas the key to Stripe; back it with aUNIQUEDB 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
PENDINGand 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_secretsynchronously. Async queues belong only after the webhook arrives — not between the user and Stripe.
1. Requirements
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
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
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.
/v1/payment_intents/:id/confirm) is handled entirely by Stripe — you do not need to design that endpoint.
4. High-Level Architecture
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)
-
Authenticate the user
Verify JWT / session cookie. Optionally enforce MFA or passkeys for high-value transactions.
-
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.
-
Create a pending order in DB
Insert an
orderrow withstatus = PENDINGbefore calling Stripe. This is your paper trail. -
Create a Stripe PaymentIntent
Call
stripe.paymentIntents.create()with an idempotency key (e.g.,order_id). Stripe returns aclient_secret. Update the order row with thepayment_intent_id. -
Return
client_secretto the browserThe 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.
-
Stripe sends a signed webhook
On charge success/failure, Stripe POSTs a
payment_intent.succeeded(orfailed) event to your webhook endpoint. -
Verify Stripe's signature
Use
stripe.webhooks.constructEvent(payload, sig, webhookSecret). Reject any event without a valid signature. -
Mark order paid & fulfill
Update
status = SUCCESSin the DB. Trigger fulfillment (ship goods, grant access). Clear the funds to the merchant via Stripe Connect. Send a confirmation notification to the user.
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.
- Simple to implement
- No public endpoint needed
- Wasted API calls
- Higher latency to detect completion
- Rate limit risk at scale
Webhook
Stripe POSTs an event to your endpoint the moment the payment status changes.
- Near-instant notification
- No wasted calls
- Scales naturally
- Requires a public HTTPS endpoint
- Must verify signature
- Must be idempotent (Stripe retries)
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:
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.
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.
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
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.
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_idandapplication_fee_amounton 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_idstored in DB — if so, return it directly without calling Stripe again - Pass the same
idempotency_key(yourorder_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]=xxxto 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-signatureheader 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
PENDINGand 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}/statusto read replicas; cache terminal statuses (SUCCESS,FAILED) in Redis since they never change - DB partitioning: at very high volume, partition Orders by
user_idhash; 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 thecharge.refundedwebhook; update order toREFUNDED; storerefund_idwith aUNIQUEconstraint - Partial refunds: track
amount_refundedalongsideamountin the Orders table — Stripe supports multiple partial refunds per PaymentIntent - Dispute (
charge.dispute.createdwebhook): 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_idand 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: 1is 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_eventsrow 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_sentflag before sending to stay idempotent — Stripe may retry the webhook, which would re-trigger the relay without this guard