API Versioning: URL, Header, or Content Negotiation? We Tried All Three
We have shipped a public API through two full versioning strategies and are currently on a third, and I think the fact that we changed our minds twice is more instructive than if I just told you the answer we landed on, because the reasons we moved say more about the tradeoffs than a clean recommendation would.
Round one: URL path versioning
We started, like most APIs do, with the version baked directly into the URL path.
GET /v1/orders/12345
GET /v2/orders/12345
The appeal is obvious and I still think it is the right starting point for most teams. It is impossible to misunderstand, visible in every log line, every browser tab, every curl command a customer pastes into a support ticket. Debugging a customer issue never involves asking which version were you actually hitting, because the answer is right there in the URL. Caching also just works with zero extra configuration, since most CDN and browser caches key on the full URL including the path, so v1 and v2 responses never collide in a shared cache the way they might with a header-based scheme unless you remember to configure Vary headers correctly.
What eventually bit us was semantic drift within a version. Our v2 launched as a genuinely coherent set of changes, but over eighteen months we accumulated eleven backward-compatible additions and, more problematically, three changes that were technically breaking for a small subset of consumers but got shipped inside v2 anyway because nobody wanted to go through a full major version bump for what felt like a minor fix. By the end, v2 quietly meant at least four different actual behaviors depending on when you had integrated, which defeats a big part of the purpose of versioning in the first place, that a given version number means one stable, predictable contract forever.
Round two: header-based versioning
Motivated by that drift, we moved to a custom header, thinking it would force more discipline.
GET /orders/12345
Api-Version: 2026-03-15
We borrowed the date-based version scheme that Stripe made popular, where the version string is just the date a given behavior was locked in, and the server picks the exact behavior matching whatever version the caller last pinned to, defaulting new integrations to the latest at signup time. This is a genuinely elegant model for the specific problem of avoiding the drift we had just been burned by, since every distinct behavior gets its own explicit date rather than getting silently folded into an existing major version number.
What we underestimated was the operational cost. Every route now needed to check a header and branch on date ranges, which meant more conditional logic scattered through handlers, more test matrix combinations, and crucially, worse debuggability for support tickets, because now a customer's browser network tab did not show the version at all unless they knew to check request headers, which support engineers reviewing a HAR file from a confused customer often did not think to do first. We also hit real friction with caching. Shared HTTP caches key on URL by default, and while Vary: Api-Version technically solves the correctness problem, plenty of CDN configurations and client libraries handle Vary headers inconsistently, and we found ourselves debugging cache poisoning issues that simply did not exist under the old URL-based scheme.
Where we landed: a hybrid, and I think this is actually the honest answer
We now use URL path versioning for major, intentionally breaking versions, currently v1 and v2 exist and are both still supported, and a lightweight header for opting into specific non-breaking behavior changes within a major version, which addresses the drift problem without paying the full operational cost of pure header-based versioning for every single request.
GET /v2/orders/12345
Api-Beta-Features: strict-validation-2026
The major version in the URL gives us the debuggability and caching benefits that made URL versioning good in the first place. The optional header gives early adopters and internal consumers a way to opt into specific forthcoming changes ahead of the next major version, without every consumer of v2 silently inheriting behavior they never agreed to, which was exactly the failure mode of round one.
What content negotiation taught us, even though we never fully adopted it
We evaluated pure content negotiation, versioning through the Accept header using custom media types, Accept: application/vnd.ourapi.v2+json, seriously enough to prototype it, and I want to give it a fair hearing because it is the technically purest of the three approaches under a strict interpretation of REST, since a resource's URL is supposed to identify the resource, not a particular representation of it. In practice, we abandoned the prototype because almost none of our customers' tooling handled custom media types gracefully. Plenty of API testing tools, internal scripts, and even some HTTP client libraries default to assuming JSON without giving developers an easy way to set a custom Accept header, which meant customer onboarding got measurably harder for zero benefit we could point to over the hybrid approach we ended up shipping instead. This might be the right call for an API with a more sophisticated, REST-purist client base, but for a general-purpose public API where a large fraction of your integrators are copying a curl example from your docs and modifying it, the theoretical elegance did not translate into practical adoption.
What I would actually tell a team starting today
Start with URL path versioning for major versions, it is the least surprising choice and it will not bite you early. Be disciplined about what counts as worth a major version bump versus what can genuinely ship as backward compatible, and write that policy down explicitly rather than deciding case by case under deadline pressure, because that ad hoc deciding is exactly what caused our drift problem in round one. Only reach for a secondary versioning mechanism, whether that is a header or something else, once you have a concrete, specific need for finer-grained control that URL versioning alone cannot give you, rather than adopting it upfront because it looks more sophisticated. We would have saved ourselves an entire migration if we had waited for that concrete need to show up before solving it speculatively.
One more detail worth sharing because it surprised us: deprecating an old major version turned out to require almost as much discipline as introducing a new one. We initially assumed we could sunset v1 once v2 adoption crossed ninety percent, but the remaining ten percent included several integrations that had gone dormant, scripts nobody at the integrating company remembered still existed, still quietly polling our v1 endpoints on a schedule. We now build a deprecation runway into the plan from the very start of a new major version, a fixed eighteen-month window communicated at launch, direct outreach to identified integrators at the six-month and one-month marks before sunset, and a hard cutoff date that does not move regardless of how many stragglers remain. Treating deprecation as a first-class part of the versioning strategy, not an afterthought once you are tired of maintaining the old version, has made every version transition since noticeably less stressful for both sides.
I would also say, looking back across all three approaches, that the specific mechanism matters less than the discipline of writing down, before you ship anything, what actually constitutes a breaking change for your specific API. We never had that document in round one, and I genuinely think that absence caused more of the drift problem than the URL-only mechanism itself did. A field rename is obviously breaking. Adding an optional field is obviously not. But plenty of real changes live in the ambiguous middle, tightening a validation rule, changing the order of items in a response array that was never documented as ordered, and having an explicit, written policy for how your team classifies those ambiguous cases removes the case-by-case judgment calls under deadline pressure that got us into trouble the first time.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.