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.
- On an account's first API request it is pinned to the version current at that moment.
- Every later call uses that pin implicitly. Nobody has to think about it.
- Upgrading is explicit and opt-in — changed in the dashboard, or overridden per request via
a
Stripe-Versionheader 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
- The key identifies the logical operation: generate once, reuse on every retry.
- Replaying the stored result replays errors too — one key, one outcome, 500s included.
- A key reused with different parameters must fail loudly, or a client bug becomes a wrong charge.
- Concurrent same-key requests should conflict, not double-execute or block; expiry bounds the window.
- Pinned rolling versions keep core code on the newest shape and push old behaviour into composable transformations maintained forever.
- At-least-once webhooks make dedup and signature verification the receiver's problem.
Check yourself
-
A client times out and retries with the same idempotency key while the first request is still executing. What should the API do?
No endpoint has completed, so there is no outcome to memoise. Executing again double-charges; blocking ties up a connection for an unbounded time. A conflict lets the caller retry once the first attempt settles, preserving the invariant that one key maps to exactly one outcome.
-
A client accidentally reuses an idempotency key for a charge with a different amount. Why must this error rather than replay the stored response?
A silent replay hides the mismatch: the caller sees success for an operation that never ran, and the two systems end up permanently out of sync about real money. Comparing parameters turns a silent wrong charge into an obvious client bug.
-
In a rolling, account-pinned versioning scheme, where does the behaviour of an old API version live?
Core code targets the current version only. A response is produced at the newest shape, then walked backwards through one module per breaking change until it matches the caller's pin. Each module is testable alone — the trade being that the chain is maintained forever and only grows.
-
What is required to consume at-least-once, signed webhooks safely?
At-least-once means the same event can arrive twice, so the receiver must deduplicate, typically by logging processed event IDs. Signature verification proves origin and must run over the raw bytes rather than a re-serialised parse, with a timestamp tolerance so an old payload cannot be replayed. Ordering is not guaranteed.