
Stripe's Michelle Bu published one of the most honest post-mortems in API design history: Stripe's payments APIs — The first 10 years. In it, she describes how a model that looked clean — Token → Charge — quietly accumulated enough exceptions and edge cases that the team eventually described maintaining it as "trying to build a spaceship by adding parts to a car."
The root cause wasn't negligence. It was a very rational, incremental process: product needs a new use case, engineering adds a parameter, the API ships. Repeat. Until the model is unrecognizable.
If you've ever worked on a public or internal API, this story is familiar. This post distills a set of techniques to keep that process in check.
Product teams think in use cases: a merchant needs X, a customer needs Y, a partner integration requires Z. API designers think in abstractions: what is the minimal mental model that correctly generalizes X, Y, Z, and the use cases we haven't seen yet?
These two modes of thinking have different time horizons. A parameter ships in a sprint. A wrong abstraction costs you years — or, in Stripe's case, a two-year rewrite, an entirely new API surface, and a migration effort that touched every SDK, every doc page, and every support article.
The tension is real and legitimate. Neither side is wrong. The question is how to manage it.
When a conceptual model fundamentally changes, introduce a new versioned resource rather than patching the old one. Stripe uses date-based versioning: each API call declares which version it was written against. Old integrators are not forced to migrate. The cost is parallel maintenance; the benefit is that the new abstraction remains conceptually clean and isn't immediately burdened by legacy constraints.
The key distinction: versioning a parameter means you're evolving the shape. Versioning an abstraction means you're evolving the model. The latter is significantly more powerful.
Set an internal threshold — say, 15–20 optional parameters on a single resource — at which point a design review is triggered automatically. This isn't a hard cap; it's a signal. In practice, a cluster of related parameters often reveals an implicit object that hasn't been named yet. The parameter budget makes that emergence visible before the model calcifies.
Not all parameters are created equal. Configuration parameters — amount, currency, description — extend the surface area of a resource without changing how it works. Behavior parameters — flags that alter the execution flow, change which party acts, or modify the state machine — are categorically different. Each new behavior parameter should trigger a hard question: is this a one-off case, or is this a sign that a distinct use case exists that deserves its own abstraction?
Stripe's Sources object failed precisely because it conflated synchronous and asynchronous payment flows under a single abstraction with diverging behavioral parameters. The eventual split into PaymentIntent (what is being paid) and PaymentMethod (how it gets processed) resolved that confusion at the model level.
Before any new parameter lands in the API, write its documentation as if it already existed. If the explanation requires two paragraphs of context — "only applicable when X is true and Y has not been set and the payment method is asynchronous..." — the mental model isn't clear yet. Shipping the parameter now embeds that confusion permanently.
This technique, borrowed from Amazon's "working backwards" practice, forces product and engineering to resolve the abstraction in prose before it gets frozen in code. It consistently surfaces whether a new parameter is truly additive or whether it exposes an unresolved design problem.