Design the Stripe Payment API

PaymentIntent, charge, refund, webhook, the cards-network interaction. The gold-standard for payments-API design.

System Advanced
15 min read
api-design stripe payments idempotency
Companies this resembles: Stripe

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 terminal succeeded?
  • 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_action and next_action to 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 POST requests via Idempotency-Key header.
  • 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_intents and POST /v1/payment_intents/{id}/confirm return 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#

MethodPathPurpose
POST/v1/customersCreate a Customer
POST/v1/payment_methodsTokenize a payment method (usually done by Stripe.js, not server)
POST/v1/payment_intentsCreate a PaymentIntent
POST/v1/payment_intents/{id}/confirmConfirm; triggers acquirer round-trip
POST/v1/payment_intents/{id}/captureCapture an authorized intent
POST/v1/payment_intents/{id}/cancelCancel a non-terminal intent
GET/v1/payment_intents/{id}Retrieve current state
POST/v1/refundsRefund a Charge
GET/v1/eventsPaginated event log (for backfill / reconcile)

Plus the webhook delivery contract: signed POSTs from Stripe to the merchant’s registered endpoint.

OpenAPI schema (excerpt)#

OpenAPI 3.1 — Stripe Payment API
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/2
Content-Type: application/json
Stripe-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.

Create and confirm a PaymentIntent — Python
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}

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:

ConditionResponse
Same key, identical request body200 with cached response
Same key, different request body409 Conflict
Same key, original request still in flight409 Conflict with Retry-After
Different keyNew request, processed normally

Trade-offs and extensions#

DecisionWhyCost if requirements change
Idempotency-Key mandatory on writesNetwork retries are the rule, not the exceptionOne header per write; merchant must generate it
confirmation_method: automatic defaultMost flows are one-stepManual confirmation needed for 3DS-heavy markets (EU SCA)
Webhook over webhook-or-callback URLOne-to-many merchant integrationsSynchronous callback is cleaner for one-merchant; not Stripe’s audience
Per-account version pinningExisting integrations never breakForces backend to support all live versions simultaneously
amount in integer minor unitsNo float precision issuesJPY (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 clientModern 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. succeeded means the card is now reusable.
  • Multi-capture. Capture a PaymentIntent across multiple POST /capture calls (used for split shipments). The current API requires a multi_capture: true flag at creation; otherwise the first capture is the only one.
  • Off-session payments (subscriptions / scheduled charges). A separate Subscription resource; 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_action mechanism 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 Account resource; the Stripe-Account header scopes API calls to a connected account. Same idempotency story, plus application_fee_amount on PaymentIntent.

Mock interview follow-ups#

  • “Why use HTTP status codes for declines instead of a decline_reason field?” — Both. 402 Payment Required for declines, with a JSON body that contains decline_code (issuer reason), failure_code (Stripe’s interpretation), and failure_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.succeeded arrive at the merchant’s webhook before the original API call returns. Merchant code must be idempotent on both paths.
  • “Why a client_secret instead of the merchant just passing the customer’s card to Stripe?” — PCI scope. The client_secret is 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 a Stripe-Version header 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. GET is already idempotent. Stripe’s contract reflects this: the header is only meaningful on POST and DELETE.
  • “What happens to a 3DS challenge if the user closes the tab?” — The PaymentIntent stays in requires_action until 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.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.