advanced

Case Study: Payment API

A payment platform's core: merchants create payment intents, confirm them against a card network, and reconcile the results.

Payments are where every abstract API-design lesson turns into money. The network *will* drop a response after the charge succeeded; the client *will* retry; the card network *will* take 4 seconds sometimes and time out other times; and a webhook *will* arrive twice, or late, or never. The design answer is not heroic infrastructure — it's a contract built from four ideas: required idempotency keys on anything that moves money (Idempotency Keys: The Mechanism), an explicit state machine instead of a mutable status string (Resources Have State Machines), errors that separate "declined" from "broken" (An Error Taxonomy Clients Can Branch On), and reconciliation as a first-class read path so no consumer ever has to trust a webhook as the source of truth. Every decision below exists because one of those failure modes is otherwise a double charge or a lost payment.

Consumers

Merchant checkout backend

Creates and confirms intents server-side during checkout; must be able to retry any call blindly after a timeout without risking a double charge.

Merchant mobile/web SDK

Polls or receives intent status to drive the payment sheet UI; needs decline reasons that are safe and useful to show a shopper.

Finance & reconciliation jobs

Nightly listing of every intent and state transition to match against the processor's settlement file; needs a complete, ordered, re-readable event history — not just current state.

Requirements

  • Create a payment intent for an amount and currency, then confirm it as a separate step (the merchant may collect payment details in between).
  • A response lost to the network must never produce a second charge when the client retries.
  • Every intent is always in exactly one explicit state, and only documented transitions are possible.
  • Declines, validation failures, and infrastructure failures must be distinguishable by machine, because the client's correct reaction differs for each.
  • Merchants learn about asynchronous outcomes (bank confirmation, disputes) via webhooks, but can always reconstruct the truth by polling.
  • Finance can reconcile: list all intents and their full event history for a time window, with stable pagination.

Resources

PaymentIntent

The durable record of *an attempt to collect an amount* — created before money moves, so there is something to retry against, query, and hang state on. Its lifecycle: `requires_confirmation` → `processing` → `succeeded` | `declined` | `failed` | `canceled`.

Charge

One concrete submission to the card network. An intent may own several (a retry after a soft decline is a *new* charge on the *same* intent). Separating them keeps "what the merchant wants" and "what the network did" from corrupting each other.

Refund

Its own resource with its own state machine, not a `DELETE` on a charge — refunds fail, partially succeed, and need idempotency of their own.

Event

An append-only record of every state transition, with a monotonically increasing sequence per intent. It is both the webhook payload and the reconciliation feed — one vocabulary for both ([[webhooks]]).

Operations

OperationPurposeDesign notes
POST /payment-intentsCreate an intent for an amount and currency.Idempotency-Key header required — the API rejects the request without one (400 IDEMPOTENCY_KEY_REQUIRED) rather than making safety opt-in. Replays with the same key return the original response, byte-for-byte, for 24h. Same key with a *different* body is 409 IDEMPOTENCY_CONFLICT: silently honoring either body would hide a client bug that involves money.
POST /payment-intents/{id}/confirmSubmit the intent to the card network.A command sub-resource, not PATCH {status: "processing"} — confirmation has parameters (payment method), side effects (a Charge is created), and can fail in ways a field write can't express (Resource or Action?). Also idempotency-keyed: confirm is the call most likely to time out mid-charge, so it's the one that most needs safe retry.
GET /payment-intents/{id}Read current state; the polling target after an ambiguous confirm.The contract documents read-after-write: a GET issued after any acknowledged mutation reflects it (Consistency as a Contract Clause). Without that promise, "poll after timeout" isn't a valid recovery strategy.
GET /payment-intentsList intents for reconciliation and dashboards.Cursor pagination ordered by (created_at, id) — finance walks millions of rows; offset pagination both collapses under deep pages and skips rows when new intents land mid-walk (Cursor Pagination: An Opaque Bookmark, Not a Position).
POST /payment-intents/{id}/cancelCancel an unconfirmed intent.Legal only from requires_confirmation; from processing it returns 409 INVALID_STATE with current_state in the body, because the money question is already with the network and the API refuses to pretend otherwise.
POST /refundsRefund a charge, fully or partially.Top-level with a charge reference rather than nested, because finance addresses refunds independently of the checkout flow. Idempotency-keyed for the same reason as create.
GET /payment-intents/{id}/eventsOrdered transition history for one intent.Answers "what happened?" after any dispute — and lets a merchant who missed webhooks rebuild state exactly.
GET /eventsGlobal event feed, cursor-paginated, filterable by type and time.The reconciliation backbone. The rule the docs state in bold: webhooks are a latency optimization; this feed is the truth. A merchant who processes only webhooks will eventually miss one (Webhook Delivery: States, Retries, Redrive).

Error contract

CodeStatusWhenRetryable
VALIDATION_FAILED400Malformed request — unknown currency, negative amount, missing field. Field-level `details` included.no
PAYMENT_DECLINED402The network refused the charge. Includes a coarse `decline_code` (`insufficient_funds`, `do_not_honor`) that is safe to show. This is a *successful* API call with a negative business outcome — never a `5xx`.no
INVALID_STATE409Operation illegal in the current state — confirming a canceled intent, canceling a processing one. Body carries `current_state` and the legal transitions.no
IDEMPOTENCY_CONFLICT409An idempotency key is reused with a different request body — almost always a client bug generating keys wrong.no
RATE_LIMITED429Merchant exceeded their request budget. `Retry-After` header set.after delay
PROVIDER_UNAVAILABLE503The card network or an internal dependency is down; nothing was charged. Safe to retry *with the same idempotency key* — the pairing that makes retry-on-5xx safe at all ([[retryability]]).after delay

Decision log

Decision → reason → alternative → trade-off. The alternative is part of the record.

Idempotency keys are mandatory on every money-moving POST, not optional.
Reason · Optional safety is unused safety: the merchant who skips the key is exactly the one whose naive timeout-retry loop double-charges. Making it required moves the failure to development time.
Alternative · Optional keys (many real processors), or server-side dedup on (amount, card, 60s) heuristics.
Trade-off · Higher integration friction and a key-store with 24h retention to operate; heuristic dedup was rejected because it silently swallows *legitimate* duplicate purchases.
Two-step create/confirm instead of a single `POST /charges`.
Reason · The intent exists *before* the risky call, so there is a stable id to poll after an ambiguous confirm timeout — the recovery path for the worst failure mode. It also naturally hosts multi-step flows (3-D Secure) later.
Alternative · One-shot charge creation.
Trade-off · Two round trips and a state machine for the simple case; one-shot is simpler right up until the first lost response, which is why the complexity is worth buying up front.
Declines are `402 PAYMENT_DECLINED`, structurally separate from `5xx`.
Reason · The client reaction differs completely: a decline goes to the shopper ("try another card"); a 503 goes to a retry loop. An API that returns 500 for declines trains merchants to retry declined cards — which card networks penalize.
Alternative · 200 with {status: "declined"} in the body (also defensible; transport signal is weaker).
Trade-off · 402 is an unusual status some middleware mishandles; the body carries the full error object so nothing is lost if the status is flattened.
Explicit state machine with named transitions; state only changes via commands, never `PATCH status`.
Reason · If PATCH {status} existed, some integration would eventually write succeeded directly. Commands make illegal transitions unrepresentable and give each transition its own authorization and side effects (Designing State Transitions).
Alternative · A writable status field with server-side transition validation.
Trade-off · More endpoints, and every new state means new operations — the rigidity is the feature.
Webhooks carry event ids and sequence numbers, and the pull-based `/events` feed is documented as the source of truth.
Reason · Delivery is at-least-once and unordered (Webhook Ordering: Assume None); merchants must dedupe by event_id and reconcile by feed. Designing the contract around that reality beats pretending delivery is reliable.
Alternative · Webhook-only outcomes with aggressive retry.
Trade-off · Merchants must build a small reconciliation job to be fully correct — the honest cost of asynchrony, stated instead of hidden.
Amounts are integer minor units (`amount: 1999`, `currency: "EUR"`), never floats.
Reason · Floating-point money corrupts silently; JSON numbers give no decimal guarantee across languages. Integers plus an explicit currency make rounding bugs loud at the boundary.
Alternative · Decimal strings ("19.99").
Trade-off · Every client does a display conversion, and zero-decimal currencies (JPY) need documentation — a small permanent tax for a large permanent safety.

How it evolves

  • 3-D Secure / SCA: a new state requires_action slots between confirm and processing, with a next_action object. Announced ahead as enum evolution: clients were instructed from V1 to treat unknown states as "in progress, poll again", so old integrations degrade to polling instead of crashing (Enum Evolution: The New Value That Broke Old Clients).
  • Partial capture: authorize-then-capture arrives as an optional capture_method: "manual" on create plus a new POST /payment-intents/{id}/capture command — purely additive; default behavior is unchanged.
  • Multiple payment methods: payment_method grows from a card object to a discriminated union with a type field that was present from day one, so adding sepa_debit is a new variant, not a reshape (Backward Compatibility: The Real Rules).
  • Disputes: a read-only Dispute resource plus new event types on the existing feed. Merchants who ignore unknown event types (the documented rule) are untouched until they opt in.

Lessons behind this design