Blog Details

blog
about

Retiring an API Version Without Breaking Enterprise Clients

Retiring an API Version Without Breaking Enterprise Clients

The v2 reflex

Somewhere in every API's third year, a developer proposes a clean fix that happens to be a breaking change. Someone else says the obvious thing: ship it as v2. That decision is cheap to make and expensive to own. You end up testing two contracts, documenting two contracts, and carrying a migration project that the largest customer keeps pushing to next quarter, because their procurement cycle does not care about your release schedule.

Versioning is a compatibility tool. Treated as a design strategy, it clones your API. The teams that retire versions quietly are the ones who version rarely and deprecate deliberately, and the distance between those two verbs is most of the work.

Most breaking changes are avoidable

Before deciding a change needs v2, ask whether the old and new behavior can coexist for a while. Usually they can.

Z. Nemec's change management rules for APIs are still the cleanest summary of how: do not take anything away, do not change processing rules, do not make optional things required, and make everything you add optional. Follow those four and most versioning problems turn back into ordinary contract evolution.

If a client reads total from an order payload, do not redefine total. Add totalMoney as an object with an amount and a currency, keep total in place, mark it deprecated, and let each consumer move when it can. The contract gets uglier. Nobody's checkout breaks.

The additions that genuinely ship without a version bump are narrow: a new optional field in a response, a new endpoint under a new path, a new optional query parameter. Three changes look additive and are not. A new enum value breaks clients with exhaustive switch statements. A new required header breaks callers that never sent it. A tighter default page size breaks integrations that assumed the old one.

The other half of additive change lives in the client. A well-built consumer ignores fields it does not recognize, so a server can add data without coordinating a release. Teams claim they want that, then generate SDK models that reject any unexpected property and write contract tests that assert exact JSON equality. That is not a compatibility strategy. It is a trap the team sets for itself and springs six months later.

What actually breaks a client

Breaking changes are usually not about the URL, which is why renaming a path is sometimes the least disruptive part of a release. Clients break when you:

  • rename or remove a field, parameter, or endpoint
  • change a field's type, format, or allowed values
  • make a previously optional parameter required
  • tighten validation that used to accept certain inputs
  • change what an HTTP status code means, or the shape of an error body
  • replace or remove an authentication mechanism
  • change pagination semantics, or the meaning of data that already ships

None of those require a new path. Turning {"total": 100} into {"total": {"amount": 100, "currency": "USD"}} breaks every client that reads total, and the endpoint never moved.

Deciding where the version label lives

Four mainstream placements exist, plus a fifth approach that avoids the decision. The choice usually gets framed as taste. It is closer to infrastructure work, because it decides your caching behavior, your SDK coupling, and what it will cost to move consumers off the old contract in three years.

URL path

/v1/orders puts the version in the cache key by default. A CDN or reverse proxy caches v1 and v2 separately with no extra configuration, and per-version TTLs just work. Debugging is trivial, since the URL tells you which contract answered. The cost is REST purity: one logical resource now lives at two URLs. For a public API whose consumers you do not control, that trade is worth taking. Kong's versioning guidance names URL path the most common REST approach, largely because it is the least surprising option available.

Header and query parameter

X-API-Version: 2 keeps URLs stable, which matters when the URL is itself part of a contract that people hardcode into spreadsheets, runbooks, and firewall rules. The catch is caching. A CDN keys on the URL by default, so two requests to /orders that differ only by header look identical to the cache. Without Vary: X-API-Version on the response, a proxy can hand a v2 payload to a v1 client. That failure is silent, intermittent, and nearly impossible to reproduce on a laptop.

Query parameters such as ?api-version=2024-10-01 sit in between. Most caches treat distinct query strings as distinct keys, but some providers strip or ignore query parameters when they compute the cache key by default. Check your provider's rule instead of assuming it. Azure Resource Manager uses a date-based query parameter and gets away with it because the entire platform is versioned the same way.

Date-based versions

A version number like v2 tells a consumer nothing beyond the fact that something changed. A date sorts correctly, maps to a changelog entry, and carries no implication about severity. GitHub and Stripe both landed there from different directions.

GitHub sends X-GitHub-Api-Version: 2026-03-10. Requests without the header fall back to 2022-11-28, which stays supported until March 10, 2028, and requests for anything older return 410 Gone. GitHub's published policy gives every version at least 24 months of support after a newer one ships.

Stripe uses the format YYYY-MM-DD.release_name, currently 2026-05-27.dahlia. Monthly releases inside a named major are backward compatible, and only the named major bundles breaking changes. Each account is pinned to the version that was current when the account was created, so shipping a new API version never changes behavior for an existing integration. Under that surface, Stripe builds responses against current resource definitions and then walks backward through version-change modules until the payload matches the pinned version. That machinery is why very old versions stay alive for years without the core code paths rotting. Copying the date format without the transformation layer gets you the cost and none of the benefit.

Stripe also documents a 72-hour window to roll back to the previous version after a major upgrade. Validate the behavior change against production traffic, then commit to it.

Schema evolution instead of versions

GraphQL makes additive change cheap because a client only receives the fields it asks for, so a new field is invisible to a query that does not request it. Fields on the way out get the @deprecated directive, which surfaces as warnings in GraphiQL, Apollo Studio, and most editor tooling. The endgame is the hard part. Field-level sunset has no equivalent of a 410 Gone on an endpoint, so you still need a schema registry or a gateway rule to enforce removal.

gRPC inherits protobuf's compatibility rules, which are stricter and more mechanical. Adding optional fields, new RPC methods, and new enum values is safe. Renaming a field, removing one, reusing a field number, or changing a field's type is not. The rule that saves teams years of pain is reserved: claim the field numbers you retire so nobody reuses them by accident.

Semantic versioning belongs to the SDK

Postman's 2025 State of the API Report found that 60% of teams version their APIs, 26% use semantic versioning, and 17% run contract testing. The semver gap is not laziness. SemVer was built so a dependency manager can judge risk from one number, and an API contract does not compress that way, because a change can be additive on the wire and still break one specific consumer's assumptions. Google's AIP-185 pushes in the opposite direction for APIs: major-only numbering, with stability expressed as a suffix such as v1beta1, and no v1.4.2 anywhere. Minor and patch numbers on an HTTP API are noise nobody asked for.

AIP-180 is the more useful document for the day job. It names three compatibility layers that all have to hold inside a version: source compatibility, wire compatibility, and semantic compatibility. Most teams test the first two and ignore the third, which is how a payload that still parses starts returning different numbers for the same input.

Deprecation is a wire protocol, not an email

Two RFCs cover the runtime signals. Sunset (RFC 8594, 2019) carries the timestamp when a resource stops responding. Deprecation (RFC 9745, published in March 2025 out of the draft-ietf-httpapi-deprecation-header work) marks a resource as deprecated and can carry a date. Send both on every response from the old version, not just once in a release note. SDKs and proxies read them, and so does the developer who inherits your integration two years from now.

GitHub does this as a version approaches closure: Deprecation carries the closure date and Sunset carries the date requests start returning 410 Gone. Add a Link header with rel="deprecation" pointing at the migration guide and the deprecation becomes visible in the docs, in live traffic, and connected to the instructions for leaving.

Then mark it in the spec. OpenAPI 3.x supports deprecated: true on an operation or a field, and that flag propagates into documentation generators, SDK generators, and linters. A deprecation that exists only in a changelog entry reaches nobody who is actually calling the endpoint.

How long to keep the old version alive

Published vendor windows are worth calibrating against, because they were set by watching real enterprises migrate:

  • GitHub: at least 24 months per version after a newer one ships, with the legacy 2022-11-28 running until March 2028.
  • Twilio: a four-stage lifecycle of Latest, Support, Deprecated, and End of Life, with 12 months of prior-version support and a full year of notice before any deprecation.
  • SailPoint: 3 years of active support plus a 2-year transition. They also moved from one annual version to per-service versioning after customers pointed out that a yearly bump forced path changes on services that had not changed at all.
  • Stripe: per-account pinning keeps old versions alive indefinitely.
  • Common practice for public APIs: a 6 to 12 month notice, with enterprise contracts frequently specifying 24 to 36 months.

The specific number matters less than writing it down and applying it to every customer. A window that changes per account is not a policy, it is a negotiation, and the account that negotiates hardest is usually the one holding your largest integration.

Measure who is still on the old version

You cannot sunset what you cannot measure. Before announcing a date, instrument the old endpoints by API key, tenant, or client ID and record which consumers call them and how often. Teams that skip this step announce a date, discover three weeks before it that a reseller's nightly batch job still hits v1, and extend the window. Measure first and the announcement becomes a fact instead of a hope.

Contract testing closes the other half of the gap. Versioning answers whether the contract changed. Consumer-driven contracts answer whether you broke anyone, which is the question customers actually ask. The pattern goes back to Ian Robinson's 2006 write-up on martinfowler.com: each consumer records the subset of the API it uses, and the provider validates every consumer contract before deploying. Pact, PactFlow, and Spring Cloud Contract implement it today. Only 17% of teams run it, and that gap explains most of the deprecation fires that versioning hygiene alone does not prevent.

A worked example: the checkout API

An e-commerce platform has a checkout API used by three storefronts, a mobile app, and two fulfillment partners. Order totals are integers in cents. Finance wants multi-currency support, which means an amount plus a currency code.

The tempting move is /v2/orders with total as an object. Every integration that reads total breaks the same afternoon, including the fulfillment partner whose contract renews in fourteen months and whose entire integration is maintained by one developer.

The sequence that survives contact with production:

  1. Ship totalMoney as an additional field and leave total in place, marked deprecated in the OpenAPI spec.
  2. Log every call to the old field by merchant and integration, so the usage number is real.
  3. Email the affected integrators with a before-and-after payload example and the date, not a link to a changelog.
  4. Return Deprecation and Sunset headers on the order endpoint, so anyone inspecting traffic sees the timeline without reading the email.
  5. At the sunset date, remove total and let the response fail loudly. A 410 or a validation error is better than silently returning a null that a fulfillment system interprets as a free order.

Total elapsed migration time for a change like this runs six to nine months at a mid-sized retailer. That sounds slow until you compare it with maintaining two order serializers, two sets of tests, and two documentation branches for the next three years.

The sunset checklist

Every clean retirement has the same parts, whatever the versioning scheme behind it:

  • a Deprecation header with a date, and a Sunset header with the removal date, on every response from the old version
  • deprecated: true in the OpenAPI spec for the affected operations and fields
  • a migration guide with concrete before-and-after payloads, written for the integrator rather than for the release notes
  • a window that matches your consumer base, with enterprise contract terms checked before the date is published
  • usage telemetry broken down by consumer, reviewed against the date before anything is removed
  • a loud failure at the end, so callers discover the change in testing rather than in a silent wrong value
  • a way to reverse the decision, whether that is a feature flag or a rollback window, for the first week after the switch

Version when the old and new semantics genuinely cannot coexist. Everything else is change management, and change management is mostly telemetry plus a published policy. Teams that get that right ship breaking changes for years and hear about them from a dashboard, not from a customer on a Friday afternoon.

Send Us a Message