Community Article

API Versioning Strategies Without the Pain

Pick a versioning strategy on the day you ship the first version. URI versioning is the safe default, calendar dating is the gold standard, and your first release IS a contract.

API Versioning Strategies Without the Pain

Pick a versioning strategy on the day you ship the first version. URI versioning is the safe default, calendar dating is the gold standard, and your first release IS a contract.

versioning
api-design
rest-api
backend
http
khalidcooper

By @khalidcooper

November 21, 2025

·

Updated May 18, 2026

509 views

9

Rate

"We don't need versioning, we'll just be careful." This is the sentence I say in every API design meeting that ends with someone reading it back to me as evidence I should be ignored. Then a year passes, three customers integrate, the marketing team adds a contract clause that includes the API behavior verbatim, and the day comes when one of the original endpoints needs to change. Now you have a real choice: break customers, build a versioning story under pressure, or freeze the design forever. None of these are good on a deadline.

I have lived through three API versioning stories and watched two more happen to teams adjacent to mine. My stance: pick a versioning strategy on the day you ship the first version, even if the strategy is "we have one version and a clear deprecation policy". Retrofitting versioning under deadline is the painful version. You can avoid it by spending an hour before launch.

What "versioning" actually has to solve

The job of an API versioning strategy is to answer four questions:

  1. How does a client say which version it wants?
  2. How does the server route the request to the right implementation?
  3. How does the server announce that an old version is going away?
  4. How does the client find out a new version exists?

A real strategy answers all four. Most teams' "strategy" answers question 1 ("we'll put /v2 in the URL") and stops there, which is why deprecation is always a fire drill.

The four common shapes

There are four shapes I see in the wild. Each handles the four questions differently.

1. URI versioning. The version is part of the URL path: /v1/orders, /v2/orders. The most visible, the most common, and the easiest to debug. Caches and proxies can route on the path, browser history shows the version, and any developer can read it.

GET /v2/orders/abc-123 HTTP/1.1
Host: api.example.com
Accept: application/json

The downside is that resource URLs are no longer canonical. The same logical resource has multiple URLs. From a strict REST purity perspective this is a violation, but in practice nobody cares. Twilio (/2010-04-01/), AWS API Gateway, and many older public APIs use URI versioning.

2. Header versioning. The version goes in a custom header: X-API-Version: 2025-11-01 or Accept: application/vnd.example.v2+json. The URL stays canonical. The server reads the header to decide which version to serve.

GET /orders/abc-123 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json

This is what RESTful purists prefer and what Stripe famously uses (with date-stamped versions in Stripe-Version). The downside is that the version is invisible in URLs, browser history, and naive logging tools. New developers will not realize the API has multiple versions until something goes wrong.

3. Query parameter versioning. GET /orders?version=2. Easier to call from a browser than header versioning. Less mainstream than URI versioning. Some teams use it for opt-in feature flags rather than versions; the line gets blurry.

4. No versioning, with backward-compatible evolution only. The API has one URL forever, and every change is required to be backward compatible. New fields are added; existing fields are not removed or repurposed. New behaviors are opt-in via parameters. Stripe's ethos is essentially this, layered on top of the date-stamped version header (Stripe-Version: 2025-11-01) which lets you pin response shape if you want to. You can think of Stripe as both option 4 and option 2 simultaneously.

I should also call out the hybrid Stripe popularized: calendar versioning. The version is a date (2025-11-01), and any breaking change ships under a new date. Clients pin to a specific date, and the server returns the response shape that date dictated. Stripe maintains compatibility with dozens of historical versions; their "tooling" for this is real and not free, but it gives clients almost-zero migration pressure.

The trade-offs that decide

ConcernURI versioningHeader versioningQuery paramNo versioning
Caller debuggabilityHighLowMediumHigh
Caching with CDNPer-version cache keyVary header (works but tricky)Per-version cache keySingle cache, easy
Canonical URLsNo (multiple URLs per resource)YesNoYes
Mainstream familiarityVery highLowerMediumMedium
Migration tooling neededManual or mass-find/replaceHeader swapParam swapNone (clients keep working)
Server complexityLow (URL routing)Higher (header parsing, version negotiation)Low (param parsing)Highest (every change has to be back-compat)
Cleanup story"Drop /v1 at end of life""Reject Accept header""Reject param""There is no cleanup; you live with everything forever"

The biggest determinant is who your callers are. Internal services with one version and one client? No versioning, just evolve. Public API with thousands of integrators? URI versioning, because debuggability matters more than purity. SDK-mediated API where clients always go through your library? Header versioning is fine because the SDK hides it. Date-stamped header (Stripe-style) only if you have the tooling and the institutional discipline to maintain many simultaneous versions.

What "breaking change" means, exactly

A working versioning strategy depends on a clear definition of what is breaking. The Hyrum's Law worry is real here: clients depend on observable behavior, including behavior you did not document. My working list of breaking vs non-breaking, in roughly the order I check them in code review:

Always breaking:

  • Removing a field from a response that clients might be reading.
  • Renaming a field in either request or response.
  • Changing a field's type (string to number, scalar to array).
  • Removing an endpoint.
  • Changing the default value of an optional input field.
  • Changing the meaning of a status code (e.g., from 200 to 201 on create).
  • Tightening validation (rejecting input that previously was accepted).
  • Changing pagination semantics (default page size, parameter names).

Usually non-breaking:

  • Adding a new optional input field.
  • Adding a new field to a response (clients should ignore unknown fields, but see Hyrum's Law).
  • Adding a new endpoint.
  • Loosening validation (accepting input that previously was rejected).
  • Adding a new optional response header.

Always check with real callers:

  • Adding a required input field (breaking for clients that didn't send it).
  • Removing a deprecated field after a long deprecation period (breaking for the long tail that ignored the deprecation warning).
  • Changing the order of items in an array if order was not documented (you would be surprised how many clients depend on the order; this is the canonical Hyrum's Law).

The "usually non-breaking" list is non-breaking only if your clients follow the protobuf-style "ignore unknown fields" discipline. JSON clients that strictly type their responses (TypeScript clients with no extra fields, mobile apps with strict serializers) treat new fields as breaking. The fix is to test additions against your largest clients before shipping.

A migration plan that has worked for me

When I am introducing a v2 (or a new dated version), the plan I follow:

v2 launch playbook
  T-30 days  : v2 design doc circulated, breaking changes listed, migration notes drafted
  T-14 days  : v2 endpoints live alongside v1, both fully supported
  T-0        : v2 announced publicly, SDK updated to default to v2
  T+30 days  : v1 marked deprecated in docs, deprecation header on every response
  T+90 days  : email to identified v1 users with "switch by [date]"
  T+180 days : v1 returns 200 with a `Sunset` header pointing at v2
  T+365 days : v1 returns 410 Gone, with a clear error pointing at migration docs

The deprecation signal lives across two RFCs: RFC 9745 (April 2024) defines the Deprecation header, and RFC 8594 defines the Sunset header. Together they are the standard. Honor it. Some clients have automation that watches for these headers and surfaces the warning to engineering teams; if you skip them, you skip the only mechanism the spec defines for telling clients "you are on a path".

The 6-month minimum between deprecation and removal is the norm for B2B APIs. Public APIs often go a year. Internal APIs with one or two known clients can compress this to weeks if the migration is real-time-coordinated.

The deprecation header most teams forget

Concrete shape:

HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1748736000
Sunset: Sun, 01 Dec 2026 00:00:00 GMT
Link: <https://api.example.com/docs/v2-migration>; rel="deprecation"; type="text/html"

The Deprecation header is when the deprecation was announced. The Sunset header is when the endpoint goes away. The Link header points at the migration guide. Clients (and their automated checks) can pick up these headers without reading docs.

The rest of the world does not implement this perfectly. Some clients ignore deprecation headers entirely. The header is still useful because it establishes a paper trail: "we told you in the response itself, with a machine-readable header, on this date". When the day comes that you turn off v1, "you had six months of warnings on every response" is a much stronger position than "we sent an email".

What I do for internal APIs

The advice above is calibrated for public APIs. Internal APIs are different. My defaults:

  1. No versioning in the URL. The cost of v1/v2 in internal traffic is not worth the operational complexity.
  2. Backward-compatible evolution. Add fields, do not rename. If you must rename, ship both fields for a release, update consumers, then remove the old one in the next release.
  3. Schema in a shared repo. OpenAPI for REST, .proto files for gRPC. PRs to the schema fail CI if they break backward compatibility (tools like buf breaking for protobuf, oasdiff for OpenAPI).
  4. Deprecation via PR comments and Slack. Internal teams can be coordinated; the public-API formality of Sunset headers is overkill.

The ratio of "internal API" to "public API" is huge in most companies. Reserve the heavy versioning machinery for the surfaces that justify it.

The mistake I made on my first public API

The mistake I made on my first public API was treating v1 as "we'll iterate". We added fields, renamed two of them when we realized the old names were wrong, and changed the meaning of a status code on a single endpoint. Nine months in, our top integrator's PagerDuty went off because we silently changed customer.created_at from a unix timestamp to an ISO 8601 string in a "minor" update. Their parser broke and stayed broken until someone in their on-call shift figured out we had changed the type.

The fix was a real versioning story added retroactively, an apology email, and a written commitment to the deprecation playbook above. The lesson: treat your first release as if every line of the response shape is a contract, because in practice it is. Anything you change later, even a "small fix", is a breaking change to someone. Plan for that on day one.

Versioning is a contract, not a feature

The phrase that finally landed for me, after enough on-call pages, is this: versioning is not a feature, it is a contract. The contract says "if you call this URL with this header, you get this response shape". Anything you do that changes that contract is breaking. The version label is the unique key for the contract. If you have one URL with no version, you have one contract that has to be honored forever, and every "small change" is either compatible or a breach.

The right time to think about versioning is the day before launch, not the day a customer's parser breaks at 2am. URI versioning is the safe default; calendar versioning is the gold standard if you have the engineering muscle for it; backward-compatible-only evolution is fine for internal APIs and small consumer counts. Pick one, write it down, and treat it like a real interface contract from the first response onward. The version is the interface; honor it like one.

Back to Articles