Engineering

What good API versioning looks like

Editorial · Reveneau · October 26, 2026

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 200 that 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

Common questions

What is the difference between URL path versioning and header versioning?

URL path versioning puts the version in the address itself, like /v1/orders, so a person can see which version they are calling just by reading the link. Header versioning puts the version in a request header instead, keeping the URL stable across versions, which some teams prefer because it treats the version as metadata about the request rather than part of the resource's identity.

What counts as a breaking change in an API?

A breaking change is one that would make an existing, correctly written client stop working or receive different data than it expects: removing a field, renaming a field, changing a field's type, changing what a status code means, or making an optional input required. Adding a new optional field or a new endpoint is not breaking, because no existing client is reading it yet.

Why is a silent breaking change worse than an obvious one?

An obvious breaking change fails loudly: a request returns an error, and the calling team sees it right away. A silent breaking change still returns a 200 success response, but the data inside has changed shape or meaning, so the failure shows up later as wrong numbers, a miscalculated total, or a support ticket, not as an alert.

Do we need a new API version for every change we make?

No. Additive, backward-compatible changes, like a new optional field or a new endpoint, should ship inside the current version. Reserve a new version for changes that would break an existing client, and even then, a deprecation window is often a better first move than forcing every caller to upgrade at once.

What is additive-only API design?

Additive-only design means every change adds something new (a field, an endpoint, an accepted value) without touching or removing anything a client already depends on. It keeps most changes inside the current version because nothing that used to work stops working.

How long should a deprecated API version stay available?

There is no universal number, and it depends on how many integrations depend on the old version and how hard they are to update. The commitment should be written down and specific, for example a fixed number of months of continued support after a new version ships, so client teams can plan the migration instead of guessing when the old version will disappear.

What is semantic versioning and does it apply to APIs?

Semantic versioning is a numbering scheme, MAJOR.MINOR.PATCH, where the major number changes only for incompatible changes and the minor number changes for backward-compatible additions, as defined by the [Semantic Versioning 2.0.0 specification](https://semver.org/). It was written for software libraries, but the same major-versus-minor thinking, breaking versus additive, applies directly to deciding when an API needs a new version.

How does Reveneau decide when a change needs a new API version?

In the work we do, we write the rule down before the first version ships: what counts as breaking, who approves an exception, and how long an old version stays supported. The eval suite we build from the spec includes contract tests against the current version's shape, so a change that would silently alter a response fails a check before it reaches anyone calling the API.

Can we add a required field without breaking existing clients?

Not without breaking them. A required field that a client's existing request does not include will fail validation the moment the new rule takes effect, which is exactly the kind of change that needs a new version or, at minimum, a default value and a deprecation notice rather than an immediate requirement.

What is the most common mistake teams make with API versioning?

The most common mistake is not choosing a strategy at all until after the first breaking change causes a problem, which means the fix gets bolted on under pressure instead of designed calmly. The second most common mistake is treating every version bump as equally disruptive, when in practice most changes should be additive and only a small number of genuine breaks ever need a new version number.