Design the Stripe Payment API
PaymentIntent, charge, refund, webhook, the cards-network interaction. The gold-standard for payments-API design.
Context#
The Stripe API is the canonical reference for “what good looks like” in payments API design. Most of the system-design diagram of a payment processor — acquirer, card networks (Visa, Mastercard, Amex), issuer, the four-corner clearing model — sits outside the API surface. The interviewer wants to test whether you can build a clean public contract on top of that mess.
We acknowledge the high-level-design overlap up front: the data-plane components (KYC, ledger, settlement, treasury) are interview-worthy in their own right but are not what an API-design round is about. This writeup focuses on the wire contract: how does the merchant integrate? What does the customer’s browser see? How do async network events become async API events?
The interviewer’s hidden objectives:
- Can you model PaymentIntent as the central resource — a state machine that survives across
requires_action(3D Secure),processing(acquirer round-trip), and into terminalsucceeded? - Can you defend idempotency keys as mandatory, not optional, on every write?
- Can you separate synchronous confirmation from asynchronous settlement via webhooks?
- Can you handle API versioning the Stripe way — pinning each account to a version date — without breaking existing integrations?
- Can you draw the card-network round trip as a sequence diagram without claiming to design the network itself?
Scope cuts to declare upfront: the ledger / treasury / connect / radar fraud detection internals, the SDK runtime, and the issuing side. We design the merchant-facing API and the webhook contract.
Requirements (functional and non-functional)#
Functional — in scope:
- Create and manage
Customer,PaymentMethod,PaymentIntent,Charge,Refund,Dispute. - Confirm a PaymentIntent (server-driven or client-driven via Elements/Stripe.js).
- Handle 3D Secure / SCA: surface
requires_actionandnext_actionto the client. - Capture a previously-authorized PaymentIntent (separate-auth-and-capture flow).
- Issue full and partial refunds against a successful Charge.
- Deliver async events via Stripe Webhooks (signed, replayable, ordered per-account-eventually).
- Mandatory idempotency on all
POSTrequests viaIdempotency-Keyheader. - Per-account API versioning pinned by date.
Functional — out of scope:
- The ledger / double-entry accounting backbone (an internal system; not part of the public API).
- The treasury / Stripe Issuing / Stripe Tax / Stripe Climate APIs (separate contracts; same shape, separate surface).
- The Radar fraud-detection model and the rule grammar (a model, not a contract).
- The SDK runtime (Stripe.js, Elements, the iOS/Android SDKs are clients; not the API).
- The acquirer / card-network protocol (ISO 8583, ISO 20022) — narrate it, but it’s not exposed.
Non-functional:
- Acknowledgement latency:
POST /v1/payment_intentsandPOST /v1/payment_intents/{id}/confirmreturn synchronously in<= 500 ms p95. They wait for the acquirer round-trip on a confirm. - Webhook delivery: best-effort within 30 s of the event; at-least-once with retries over 72 h.
- Availability: 99.99% on the API; 99.9% on webhook delivery (event store is durable; delivery resumes on outage recovery).
- Idempotency window: 24 h. A retry within that window returns the original response byte-for-byte.
- Throughput: peak processing of 10k+ transactions per second on Black Friday / Cyber Monday; sustained 1k tps day-to-day.
Use case diagram#
┌──────────────┐ ┌──────────────┐ │ Customer │ (browser) │ Merchant │ (server) └──────┬───────┘ └──────┬───────┘ │ │ │ pay [Elements / Checkout] │ create PaymentIntent │ │ confirm capture refund ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ Stripe Payment API │ └──────────────────────────┬───────────────────────────────┘ │ ┌───────────────┼────────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ Acquirer │ │ Webhook │ │ Ledger │ (internal) └────┬─────┘ │ delivery │ └────────────┘ │ └────┬─────┘ ▼ ▼ ┌──────────┐ Merchant endpoint │ Card │ │ networks │ └────┬─────┘ ▼ ┌──────────┐ │ Issuer │ └──────────┘Two actors at the API surface: customer (via Elements, indirectly) and merchant (server-to-server). Everything to the right of the dashed line is invisible to the public contract.
Class diagram#
┌──────────────────────┐ ┌──────────────────────┐ │ Customer │ 1 * │ PaymentMethod │ ├──────────────────────┤◄──────┤──────────────────────┤ │ id : cus_* │ │ id : pm_* │ │ email : string │ │ type : enum (card, │ │ default_pm : pm_* │ │ sepa, ach) │ │ metadata : map │ │ card : { last4, …} │ └──────────────────────┘ └──────────┬───────────┘ │ ▼ ┌──────────────────────┐ │ PaymentIntent │ ├──────────────────────┤ │ id : pi_* │ │ amount : int (cents) │ │ currency : iso │ │ status : state │ │ customer : cus_*? │ │ payment_method : pm_*?│ │ confirmation_method │ │ capture_method │ │ next_action : action?│ │ client_secret : str │ │ latest_charge : ch_*?│ └──────────┬───────────┘ │ 1 ▼ 0..1 ┌──────────────────────┐ │ Charge │ ├──────────────────────┤ │ id : ch_* │ │ amount : int │ │ amount_captured : int│ │ status : succeeded │ │ pending │ │ failed │ │ payment_intent : pi_*│ │ balance_tx : txn_* │ └──────────┬───────────┘ │ 1 * ▼ ┌──────────────────────┐ │ Refund │ ├──────────────────────┤ │ id : re_* │ │ amount : int │ │ charge : ch_* │ │ status : enum │ │ reason : enum? │ └──────────────────────┘PaymentIntent is the central resource. A Charge is the immutable record of a single attempt against the card networks; one PaymentIntent can have multiple charge attempts (one for each retry) but only the most recent one is “live” for capture and refund. Refund attaches to Charge, not PaymentIntent — refunds are network-level.
Sequence diagram (key flows)#
The full payment flow, separate-auth-and-capture model:
Browser Merchant StripeAPI Acquirer CardNetwork Issuer Webhook │ │ POST /payment_intents │ │ │ │ │ │ │─────────────────────► │ │ │ │ │ │ │ pi_* + client_secret │ │ │ │ │ │ │◄─────────────────────│ │ │ │ │ │ pi_* + client_secret │ │ │ │ │ │ │◄─────────────│ │ │ │ │ │ │ stripe.js confirmCardPayment(secret, pm) │ │ │ │ │ │──────────────────────────────────────────►│ │ │ │ │ │ │ │ authorize │ │ │ │ │ │ │─────────────►│ ISO 8583 │ │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ │ 0100 auth │ │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ │ verify │ │ │ │ │ │ │ + 3DS? │ │ │ │ │ │ 0110 chal │ │ │ │ │ │ │◄─────────────│ │ │ │ │ │ challenge │ │ │ │ │ │ │◄─────────────│ │ │ │ next_action: redirect_to_url │ │ │ │ │ │◄──────────────────────────────────────────│ │ │ │ │ (user completes 3DS) │ │ │ │ │ confirmCardPayment ack │ │ │ │ │──────────────────────────────────────────►│ │ │ │ │ │ │ post-3DS │ │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ │ 0100 auth │ │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ │ 0110 ok │ │ │ │ │ │ │◄─────────────│ │ │ │ │ authorized │ │ │ │ │ │ │◄─────────────│ │ │ │ │ │ │ │ │ │ │ │ │ │ emit payment_intent.succeeded │ │ │ │ │─────────────────────────────────────────────────────────►│ │ │ POST /webhook (signed) │ │ │ │ │ │◄─────────────────────────────────────────────────────────────────────│The 3DS round trip is the path where most candidates lose precision. next_action.redirect_to_url is the public contract for “browser needs to do something I can’t do server-side.”
Activity diagram (for non-trivial state)#
The PaymentIntent state machine:
[POST /payment_intents] │ ▼ ┌───────────────────────────┐ │ requires_payment_method │ ── pm not yet attached └────────────┬──────────────┘ │ attach payment_method ▼ ┌───────────────────────────┐ │ requires_confirmation │ └────────────┬──────────────┘ │ POST /confirm ▼ ┌────────┴────────────┐ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ │ requires_action │ │ processing │ │ (3DS, redirect) │ └────────┬─────────┘ └────────┬─────────┘ │ │ client completes │ ▼ │ ┌──────────────────┐ │ │ processing │◄──────────┘ └────────┬─────────┘ │ ┌────────┼───────────────┬──────────────┐ ▼ ▼ ▼ ▼ succeeded requires_capture canceled requires_payment_method (retry with different pm)requires_capture is the separate-auth-and-capture branch: the issuer has authorized funds but the merchant has not yet captured. Used by e-commerce (capture on ship), hotels (capture on checkout), car rentals (capture on return).
API implementation#
Endpoint catalogue#
| Method | Path | Purpose |
|---|---|---|
POST | /v1/customers | Create a Customer |
POST | /v1/payment_methods | Tokenize a payment method (usually done by Stripe.js, not server) |
POST | /v1/payment_intents | Create a PaymentIntent |
POST | /v1/payment_intents/{id}/confirm | Confirm; triggers acquirer round-trip |
POST | /v1/payment_intents/{id}/capture | Capture an authorized intent |
POST | /v1/payment_intents/{id}/cancel | Cancel a non-terminal intent |
GET | /v1/payment_intents/{id} | Retrieve current state |
POST | /v1/refunds | Refund a Charge |
GET | /v1/events | Paginated event log (for backfill / reconcile) |
Plus the webhook delivery contract: signed POSTs from Stripe to the merchant’s registered endpoint.
OpenAPI schema (excerpt)#
paths: /v1/payment_intents: post: operationId: createPaymentIntent parameters: - name: Idempotency-Key in: header required: true schema: { type: string, maxLength: 255 } - name: Stripe-Version in: header required: false description: API version override; defaults to account-pinned schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' } requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object required: [amount, currency] properties: amount: type: integer description: Amount in the currency's smallest unit (cents for USD) minimum: 50 currency: { type: string, pattern: '^[a-z]{3}$' } customer: { type: string, pattern: '^cus_' } payment_method: { type: string, pattern: '^pm_' } confirmation_method: { type: string, enum: [automatic, manual] } capture_method: { type: string, enum: [automatic, manual] } metadata: type: object additionalProperties: { type: string } maxProperties: 50 responses: '200': description: PaymentIntent created content: application/json: schema: { $ref: '#/components/schemas/PaymentIntent' } '402': { description: Payment required (declined) } '409': { description: Idempotency-Key conflict }
/v1/payment_intents/{id}/confirm: post: operationId: confirmPaymentIntent parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: Idempotency-Key, in: header, required: true, schema: { type: string } } requestBody: content: application/x-www-form-urlencoded: schema: type: object properties: payment_method: { type: string } return_url: { type: string, format: uri } responses: '200': description: Updated intent content: application/json: schema: { $ref: '#/components/schemas/PaymentIntent' }
components: schemas: PaymentIntent: type: object required: [id, object, amount, currency, status] properties: id: { type: string, pattern: '^pi_' } object: { type: string, enum: [payment_intent] } amount: { type: integer } currency: { type: string } status: type: string enum: - requires_payment_method - requires_confirmation - requires_action - processing - requires_capture - succeeded - canceled client_secret: { type: string } next_action: type: object nullable: true properties: type: { type: string, enum: [redirect_to_url, use_stripe_sdk] } redirect_to_url: type: object properties: url: { type: string, format: uri } return_url: { type: string, format: uri } latest_charge: { type: string, nullable: true }Webhook delivery contract#
Webhooks are signed POSTs. The signature header is the bedrock of trust — without it, anyone could spoof a payment_intent.succeeded event.
POST /webhooks/stripe HTTP/2Content-Type: application/jsonStripe-Signature: t=1717180000,v1=5257a869e7ecebeda32affa6...,v0=...
{ "id": "evt_1H8...", "object": "event", "type": "payment_intent.succeeded", "api_version": "2025-04-30.preview", "created": 1717180000, "data": { "object": { "id": "pi_3H8...", "object": "payment_intent", "amount": 2500, "currency": "usd", "status": "succeeded", "latest_charge": "ch_3H8..." } }, "request": { "id": "req_abc...", "idempotency_key": "cus-order-7842" }}The signature is HMAC-SHA256 over timestamp.body with the webhook signing secret. Merchants must verify or attackers can fake “you got paid” events.
Retries follow exponential backoff over 72 h: roughly 1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h. After 72 h the event is moved to a dead-letter view that the merchant can replay manually via POST /v1/webhook_endpoints/{id}/replay.
Client samples — three languages#
Create + confirm a PaymentIntent on the server. The “client side” (Stripe.js, Elements) handles 3DS in the browser and isn’t shown here.
import os, uuid, stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def charge_customer(customer_id, amount_cents, currency="usd", order_id=None): idem = order_id or str(uuid.uuid4()) intent = stripe.PaymentIntent.create( amount=amount_cents, currency=currency, customer=customer_id, confirmation_method="manual", capture_method="automatic", metadata={"order_id": idem}, idempotency_key=f"create-{idem}", ) confirmed = stripe.PaymentIntent.confirm( intent.id, payment_method=intent.customer and "pm_card_visa", return_url="https://merchant.example.com/return", idempotency_key=f"confirm-{idem}", ) if confirmed.status == "requires_action": return {"action": "redirect", "url": confirmed.next_action.redirect_to_url.url} if confirmed.status == "succeeded": return {"status": "ok", "charge": confirmed.latest_charge} return {"status": confirmed.status}package main
import ( "github.com/stripe/stripe-go/v76" "github.com/stripe/stripe-go/v76/paymentintent")
func chargeCustomer(customerID string, amountCents int64, orderID string) (*stripe.PaymentIntent, error) { params := &stripe.PaymentIntentParams{ Amount: stripe.Int64(amountCents), Currency: stripe.String(string(stripe.CurrencyUSD)), Customer: stripe.String(customerID), ConfirmationMethod: stripe.String("manual"), CaptureMethod: stripe.String("automatic"), } params.AddMetadata("order_id", orderID) params.IdempotencyKey = stripe.String("create-" + orderID)
intent, err := paymentintent.New(params) if err != nil { return nil, err }
cparams := &stripe.PaymentIntentConfirmParams{ PaymentMethod: stripe.String("pm_card_visa"), ReturnURL: stripe.String("https://merchant.example.com/return"), } cparams.IdempotencyKey = stripe.String("confirm-" + orderID) return paymentintent.Confirm(intent.ID, cparams)}import Stripe from "stripe";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function chargeCustomer(customerId, amountCents, orderId) { const intent = await stripe.paymentIntents.create( { amount: amountCents, currency: "usd", customer: customerId, confirmation_method: "manual", capture_method: "automatic", metadata: { order_id: orderId }, }, { idempotencyKey: `create-${orderId}` }, );
const confirmed = await stripe.paymentIntents.confirm( intent.id, { payment_method: "pm_card_visa", return_url: "https://merchant.example.com/return" }, { idempotencyKey: `confirm-${orderId}` }, );
if (confirmed.status === "requires_action") { return { action: "redirect", url: confirmed.next_action.redirect_to_url.url }; } return { status: confirmed.status, charge: confirmed.latest_charge };}The two-axis idempotency model#
Stripe’s idempotency model is the canonical reference because it scopes uniqueness to (api_key, idempotency_key) for 24 hours. A second request with the same key returns the original response byte-for-byte — including 200, 4xx, and 5xx. This is why merchants can retry network failures without fear.
The contract for handling collisions:
| Condition | Response |
|---|---|
| Same key, identical request body | 200 with cached response |
| Same key, different request body | 409 Conflict |
| Same key, original request still in flight | 409 Conflict with Retry-After |
| Different key | New request, processed normally |
Trade-offs and extensions#
| Decision | Why | Cost if requirements change |
|---|---|---|
| Idempotency-Key mandatory on writes | Network retries are the rule, not the exception | One header per write; merchant must generate it |
confirmation_method: automatic default | Most flows are one-step | Manual confirmation needed for 3DS-heavy markets (EU SCA) |
| Webhook over webhook-or-callback URL | One-to-many merchant integrations | Synchronous callback is cleaner for one-merchant; not Stripe’s audience |
| Per-account version pinning | Existing integrations never break | Forces backend to support all live versions simultaneously |
amount in integer minor units | No float precision issues | JPY (no minor unit) and BHD (3 decimal places) are special cases in docs |
Form-encoded request bodies (application/x-www-form-urlencoded) | Legacy, but works with every HTTP client | Modern designs prefer JSON; Stripe’s contract predates that consensus |
Likely follow-up extensions and the shape of the answer:
- Setup Intents for future payments. Same shape as PaymentIntent but with
amount=null— authorize a payment method without charging.succeededmeans the card is now reusable. - Multi-capture. Capture a PaymentIntent across multiple
POST /capturecalls (used for split shipments). The current API requires amulti_capture: trueflag at creation; otherwise the first capture is the only one. - Off-session payments (subscriptions / scheduled charges). A separate
Subscriptionresource; uses stored payment methods; SCA exemption flags (off_session: true) tell the issuer this is a merchant-initiated charge. - Strong Customer Authentication (PSD2 / SCA). The 3DS flow is the default for EU cards; the
next_actionmechanism is what makes the API EU-compliant. Already in scope; the extension is the exemption machinery (low-value, TRA, recurring). - Connect (platforms paying out to sub-merchants). A separate
Accountresource; theStripe-Accountheader scopes API calls to a connected account. Same idempotency story, plusapplication_fee_amounton PaymentIntent.
Mock interview follow-ups#
- “Why use HTTP status codes for declines instead of a
decline_reasonfield?” — Both.402 Payment Requiredfor declines, with a JSON body that containsdecline_code(issuer reason),failure_code(Stripe’s interpretation), andfailure_message. HTTP status gives load balancers and proxies a hook; the body gives the merchant precision. - “How does Stripe handle the case where a webhook fires before the API response returns?” — The webhook and the synchronous response are independent. A confirm call that takes 3 seconds because the issuer is slow can absolutely have
payment_intent.succeededarrive at the merchant’s webhook before the original API call returns. Merchant code must be idempotent on both paths. - “Why a
client_secretinstead of the merchant just passing the customer’s card to Stripe?” — PCI scope. Theclient_secretis bound to one PaymentIntent and one customer; Stripe.js uses it to tokenize the card client-side and confirm — the merchant’s server never touches the PAN. This collapses the merchant’s PCI scope from SAQ-D to SAQ-A. - “How is API versioning done?” — Each account is pinned to a version string (a date, e.g.
2025-04-30.preview). All API calls without aStripe-Versionheader use the pinned version. Stripe maintains backwards-compat shims for every published version going back years; an account explicitly upgrades when ready. - “Idempotency-Key on
GET?” — Pointless.GETis already idempotent. Stripe’s contract reflects this: the header is only meaningful onPOSTandDELETE. - “What happens to a 3DS challenge if the user closes the tab?” — The PaymentIntent stays in
requires_actionuntil the merchant explicitly cancels (POST /cancel) or 3DS times out at the issuer (typically 15 minutes). After that the next confirm attempt restarts the flow. - “How does Stripe handle the dual-write problem — DB and API both must succeed?” — Outbox pattern. Merchant writes to local DB + outbox in one transaction; a background worker calls Stripe and retries on failure using the same idempotency key. This is standard merchant integration advice from Stripe’s own docs.
Related#
- The Role of Idempotency in API Design — the foundational concept Stripe took to its purest expression.
- Event-Driven Architecture Protocols — webhook delivery uses this shape.
- API Versioning — Stripe’s per-account version pinning is the textbook example.
- The API-Design Walk-through — the seven-step recipe this writeup followed.
- REST — The Architectural Style — the architectural style behind the endpoint shape.