Tier 4 · Case Studies

Case Study — Stripe's API

Idempotency keys, rolling versions, and what it costs to promise you will never break

⏱ 16 min case-studyapiidempotency

The constraint that shapes everything

A payments API starts not from throughput but from who calls it: code you cannot read or fix, on servers you cannot upgrade, moving money that cannot be moved twice and cannot be un-moved cheaply.

That gives two commitments which never expire. A duplicate charge is unacceptable, so every retry must be harmless. A breaking change is unacceptable, so the response an old integration gets today must match what it got the day it was written. Neither is shipped once; both constrain every change afterwards.

Idempotency keys in production

The mechanism is covered in idempotency. The edges are what is worth studying.

Stripe accepts an Idempotency-Key header on POST requests; GET and DELETE do not take one, being idempotent already. The key identifies the logical operation, not the HTTP attempt, so it is generated once before the first send and reused on every retry. Stripe suggests V4 UUIDs. A key regenerated per attempt is not weaker protection, it is none.

On a duplicate, Stripe replays the saved status code and body of the first request, including a 500. Retrying a genuinely failed request with the same key returns the same failure rather than re-executing, because a key maps to one outcome. A fresh attempt needs a fresh key — and is a new logical operation.

Second request, same key What happens Why
First one finished Original status and body replayed One key, one outcome
First one still running Conflict error, nothing saved Never execute twice, never block
Different parameters Error A client bug must not become a silent wrong charge
Past the retention window Executes as new Bounded storage, bounded promise

In flight is the case textbook write-ups skip. A retry can arrive while the first request is still executing — an impatient client timeout. Stripe treats it as a conflict and rejects the second rather than running the work twice or blocking on it. Nothing is saved against the key, since no endpoint completed, so the caller can retry once the first settles.

Keys expire. Results are stored for at least 24 hours and may then be pruned; a key reused after that executes again. A bounded window, not a guarantee — bounded because an unbounded key table is a permanent storage liability.

Rolling versions: the interesting part

Stripe's API versions are dates with a botanical release name attached, in the shape 2026-06-24.dahlia (names run alphabetically — acacia, basil, clover, and onward; check the docs for the current one). Named releases may carry breaking changes; monthly releases within a family are additive only.

  1. On an account's first API request it is pinned to the version current at that moment.
  2. Every later call uses that pin implicitly. Nobody has to think about it.
  3. Upgrading is explicit and opt-in — changed in the dashboard, or overridden per request via a Stripe-Version header so you can test the new shape before committing.

A 2016 integration keeps getting 2016-shaped responses indefinitely, its authors doing nothing.

The implementation is the part worth stealing. Stripe does not keep a copy of the codebase per version. Core code only knows the newest version. A response is built at the current version; Stripe then determines the caller's target version and walks backwards through time, applying a chain of small version-change modules — one per backwards-incompatible change — until the payload matches what that version promised. Requests transform the other way, from an old caller's shape into what current code expects.

Each module declares which resources and fields it touches. That declaration also drives versioned docs and changelog generation, and makes each module independently testable, because a module is a pure function from one shape to an adjacent one.

The power: old behaviour becomes data, not code paths. No if version < X scattered through charge logic. Contrast path versioning:

Path versioning (/v1, /v2) Rolling, pinned versions
Old behaviour lives in A parallel implementation A composable transformation
Core code knows Every supported version Only the newest
A breaking change costs Forking the surface One new module
Caller migration Rewrite against a new base path Flip a setting, test with a header
Failure mode N implementations drift; fixes land in one The chain grows and never shrinks

Webhooks are an API too, and a harder one

Outbound events reverse the direction: now the merchant's endpoint is the server, and it will be down sometimes.

Delivery is at-least-once and unordered. Stripe does not guarantee events arrive in the order generated, and retries a failing endpoint with exponential backoff for up to three days in live mode. Consumers must therefore be idempotent — the same conclusion as idempotency, now imposed on code you do not own. Every event carries a unique id; logging processed IDs and skipping repeats is the documented approach.

Authenticity is the receiver's job. The Stripe-Signature header carries a timestamp and an HMAC-SHA256 over the timestamp joined to the raw body, keyed by that endpoint's signing secret. Verify by recomputing, comparing in constant time, and rejecting timestamps outside a tolerance (five minutes by default) so a captured payload cannot be replayed later. The classic production bug is verifying against a re-serialised JSON body rather than the raw bytes — a failure that looks like a Stripe outage and is not.

Payloads are versioned too: the shape is fixed by the API version in effect when the event occurred, not when you read it. Handlers should acknowledge fast and work asynchronously, or a slow handler becomes a timeout, becomes a retry, becomes load — a queue in front (queues-and-logs) plus timeouts behind (resilience). A webhook you cannot verify or deduplicate is a liability: an unauthenticated instruction to change your state, delivered an unknown number of times.

The ledger underneath

Stripe has published that its Ledger is an immutable, append-only record of money movement using double-entry bookkeeping, so debits and credits must balance, at a volume it describes as billions of events per day.

Immutable is the operative word. Entries are never updated; a correction is a new compensating entry. Past state is reconstructable by replay, and a bug fix cannot quietly rewrite what happened — the same instinct as the append-only structures in transactions. What occurred and what the balance is are different things, and only the second is derived.

The general lesson

The hard part here is not throughput. It is that correctness and compatibility are both permanent commitments, and permanent commitments must be paid for structurally rather than by discipline. Idempotency keys make retries safe by construction, so no client has to be careful. Version-change modules keep old behaviour out of the core, so no engineer has to remember 2016. Each is a running cost accepted deliberately, in exchange for never sending the email that begins: on 1 March, the following field will change.

What to take away

Check yourself

  1. A client times out and retries with the same idempotency key while the first request is still executing. What should the API do?

  2. A client accidentally reuses an idempotency key for a charge with a different amount. Why must this error rather than replay the stored response?

  3. In a rolling, account-pinned versioning scheme, where does the behaviour of an old API version live?

  4. What is required to consume at-least-once, signed webhooks safely?