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
Creates and confirms intents server-side during checkout; must be able to retry any call blindly after a timeout without risking a double charge.
Polls or receives intent status to drive the payment sheet UI; needs decline reasons that are safe and useful to show a shopper.
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
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`.
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.
Its own resource with its own state machine, not a `DELETE` on a charge — refunds fail, partially succeed, and need idempotency of their own.
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
| Operation | Purpose | Design notes |
|---|---|---|
| POST /payment-intents | Create 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}/confirm | Submit 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-intents | List 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}/cancel | Cancel 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 /refunds | Refund 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}/events | Ordered transition history for one intent. | Answers "what happened?" after any dispute — and lets a merchant who missed webhooks rebuild state exactly. |
| GET /events | Global 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
| Code | Status | When | Retryable |
|---|---|---|---|
| VALIDATION_FAILED | 400 | Malformed request — unknown currency, negative amount, missing field. Field-level `details` included. | no |
| PAYMENT_DECLINED | 402 | The 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_STATE | 409 | Operation illegal in the current state — confirming a canceled intent, canceling a processing one. Body carries `current_state` and the legal transitions. | no |
| IDEMPOTENCY_CONFLICT | 409 | An idempotency key is reused with a different request body — almost always a client bug generating keys wrong. | no |
| RATE_LIMITED | 429 | Merchant exceeded their request budget. `Retry-After` header set. | after delay |
| PROVIDER_UNAVAILABLE | 503 | The 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.
(amount, card, 60s) heuristics.503 goes to a retry loop. An API that returns 500 for declines trains merchants to retry declined cards — which card networks penalize.200 with {status: "declined"} in the body (also defensible; transport signal is weaker).402 is an unusual status some middleware mishandles; the body carries the full error object so nothing is lost if the status is flattened.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).event_id and reconcile by feed. Designing the contract around that reality beats pretending delivery is reliable."19.99").How it evolves
- • 3-D Secure / SCA: a new state
requires_actionslots between confirm andprocessing, with anext_actionobject. 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 newPOST /payment-intents/{id}/capturecommand — purely additive; default behavior is unchanged. - • Multiple payment methods:
payment_methodgrows from a card object to a discriminated union with atypefield that was present from day one, so addingsepa_debitis a new variant, not a reshape (Backward Compatibility: The Real Rules). - • Disputes: a read-only
Disputeresource plus new event types on the existing feed. Merchants who ignore unknown event types (the documented rule) are untouched until they opt in.