What good API versioning looks like

We have sat in the planning meeting where someone asks, "does this need a new version?" and the honest answer is nobody wrote down a rule to check it against. The team guesses, ships, and finds out three weeks later that a partner's integration broke because a field that used to always be a string started showing up as a number for certain accounts. Nobody bumped anything. Nothing in the response said the shape had changed.
That is the failure this post is about. Good API versioning is not a clever numbering scheme. It is a small set of decisions, made once and written down, that tell every future engineer exactly when a change is safe to ship quietly and when it needs a signal loud enough for every caller to notice.
Three ways to signal a version, and what each one actually buys you
There are three common places to put a version number, and they solve different problems.
URL path versioning puts the version in the address: /v1/orders versus /v2/orders. Anyone reading a log line, a bug report, or a curl command can see which version was called without opening a single header. That visibility is the whole appeal. The cost is that every resource technically lives at two different addresses once you have two versions, and a lot of teams end up duplicating routing logic to keep both paths working.
Header versioning keeps one URL and puts the version in a request header instead. GitHub uses this approach for its REST API: every request can include an X-GitHub-Api-Version header naming a date, and a request that omits the header falls back to a default version rather than failing (GitHub Docs). GitHub also commits to supporting a previous version for at least 24 months after a new one ships, which is the kind of concrete, written promise that lets client teams plan a migration on their own schedule instead of scrambling. The tradeoff is that the version is invisible unless someone thinks to check the header, so a support engineer debugging a live issue has to go looking for it.
Additive-only design is not really a third place to put a version number. It is a design discipline that tries to avoid needing a new version at all. Stripe is the clearest public example: instead of numbered versions, Stripe names each version by the date it was released, pins every new account to the version live on the day they sign up, and never moves that pin unless the account holder asks to upgrade (Stripe API versioning blog). Internally, Stripe's core logic only understands the newest shape of the data. A response compatibility layer then translates that response backward into whatever shape each pinned version expects, according to Stripe's own account of the system (Stripe versioning and support policy). The result: code written years ago against an old version keeps working with no forced migration, because new fields get added without touching what already exists.
None of these three replace the other two. A team can put the version in the URL for visibility and still run an additive-only discipline internally to keep new versions rare. What matters more than which one you pick is the next question.
The rule that decides when a change actually needs a new version
Most engineering teams do not fail at versioning because they picked the wrong mechanism. They fail because nobody wrote down, in one place, what counts as breaking.
Here is a rule that is short enough to actually get followed. A change needs a new version, or at minimum a deprecation path, if an existing client, written correctly against the current version and never updated, would get an error or a different answer after the change ships. Walk through the common cases:
- Adding a new optional field to a response. Not breaking. No existing client is reading that field yet, so nothing they do changes.
- Adding a new endpoint. Not breaking, for the same reason.
- Removing a field, or renaming one. Breaking. Any client reading that field now gets nothing, or gets the old value under a name it does not recognize.
- Changing a field's type, string to number, or a flat value to a nested object. Breaking, even though the field is still there under the same name. This is the change most likely to slip past a casual review, because a diff of field names looks unchanged while the actual meaning of the response has shifted underneath it.
- Making a previously optional input required. Breaking. A request that used to succeed now fails validation.
- Changing what a status code means, for example a
200that used to mean "created" now sometimes means "queued for later processing" with no other signal. Breaking, because the client's existing logic for handling that code no longer matches reality.
This is close to how the Semantic Versioning 2.0.0 specification frames it for software libraries: the major number moves only for changes that are not backward compatible, and the minor number moves for additions that do not break anything already in place. APIs are not libraries, but the same major-versus-minor thinking works: most of what a team wants to ship is additive and belongs in the current version, and only the genuinely incompatible changes need the loud signal of a new version.
The one case worth calling out separately is a required field on write. Adding a required field to an incoming request is breaking by definition, because a client that has not been told about it will submit a request missing that field and get rejected. If the field genuinely needs to exist going forward, the safer path is usually: add it as optional with a sensible default, announce a deprecation window for callers who do not set it, and only make it a hard requirement once that window closes, or once a new version ships that clients have to opt into.
Deprecation is a promise, not a delay tactic
A deprecation path only works if it is a specific, written commitment: this version keeps working until this date, or for this many months after the replacement ships. GitHub's 24-month commitment is useful here not because that exact number is the right one for every API, but because it shows what a real commitment looks like: a number, attached to a trigger, that a client team can put on their own calendar.
A deprecation notice that just says "this will be removed in a future version" is not a deprecation path. It is a warning with no date attached, and most engineering teams treat an undated warning the same way they treat a low-priority ticket: they will get to it eventually, which in practice means they get to it during the outage.
The failure mode worth naming: the silent breaking change
The specific failure worth designing against is the silent breaking change: a change that alters what a response means or contains, but ships without any version bump, without a changelog entry a caller would see, and without an error response that would flag it during testing. The request still returns success. The data inside is different, or missing, or a different type than before.
This is worse than an obvious break for one reason: an obvious break fails loudly, right away, in a way the team that broke it can see. A silent break shows up downstream, in a system nobody was watching, as a wrong total on an invoice, a null value crashing a report the following month, or a customer noticing before the engineering team does. By the time it is diagnosed, it can be hard to even tell when the change shipped, because nothing marked the moment it happened.
The fix is not a smarter versioning scheme. It is a contract check that runs before every deploy: a test that asserts the current version's response shape and fails the build if a field disappears, changes type, or changes meaning without that change being a deliberate, reviewed decision. In the work we do, this is exactly the kind of check that belongs in the eval suite we write from the specification before a build ships: a rule about what the current API version guarantees, checked automatically, every time, rather than trusted to whoever happens to review that one pull request.
The aphorism
A version number is not a label you add after something breaks. It is a promise you write down before anything ships, so that when a change does break the promise, everyone finds out before the client does.
Sources
- Stripe: APIs as infrastructure, future-proofing Stripe with versioning: describes Stripe's dated, per-account pinned versioning and its backward-compatibility layer.
- Stripe: versioning and support policy: Stripe's own documentation of how version pinning and upgrades work for API accounts.
- GitHub Docs: API Versions: documents the
X-GitHub-Api-Versionheader, the default-version fallback, and the 24-month support commitment for prior versions. - Semantic Versioning 2.0.0: the specification defining MAJOR (incompatible changes) versus MINOR (backward-compatible additions) versioning.


