Stop Breaking Your API Consumers — A Practical Guide to API Versioning That Works
I've been on both sides of the broken API.
As a consumer: waking up to alerts because someone shipped a "minor" change that renamed a field. As a builder: shipping that exact change myself, thinking "nobody relies on this field" — only to find out three teams do.
API versioning isn't glamorous. Nobody puts "implemented versioning strategy" on their resume as a highlight. But it's the difference between an API that your consumers trust and one they actively work around.
Here's what I've learned from building (and breaking) production APIs over the last few years.
The Three Strategies — and When Each One Fails
1. URL Versioning (/v1/users, /v2/users)
What it looks like:
GET /api/v1/users
GET /api/v2/users
The good: Dead simple. Every consumer knows exactly which version they're calling. No parsing headers, no negotiation — just a URL.
The bad: URL pollution. After 3 years you might have /v1, /v2, /v3 and /v4-beta all alive simultaneously. Your routing file becomes a graveyard of deprecated endpoints you're afraid to delete.
The ugly: Some developers treat it like software versioning and create a new major version for every small change. If you have /v17/users, you've lost the plot.
When it works best: Public APIs with long deprecation windows. If you can commit to supporting /v1 for 12 months after /v2 ships, this is your best bet.
2. Header Versioning (Accept: application/vnd.myapi.v1+json)
What it looks like:
curl -H "Accept: application/vnd.myapi.v1+json" https://api.example.com/users
The good: Clean URLs forever. Your endpoints don't change paths — the version is metadata.
The bad: Harder to test in the browser. Can't just paste a URL into your address bar. New developers on your API consistently forget the header and get confused by error responses.
The ugly: I once spent 45 minutes debugging a "broken" integration before realizing the client library was sending v2 in the header while I was testing against v1 routes. Headers are invisible until they bite you.
When it works best: Internal APIs where every consumer uses a client library you also control. GitHub uses this for their API and it works — but they have the tooling to support it.
3. Query Parameter Versioning (/users?version=1)
What it looks like:
GET /api/users?version=1
The good: Easy to test, easy to default (omit the parameter = latest version).
The bad: Caching gets complicated. ?version=1 and ?version=2 might hit different cache keys, or worse — share the same key and return the wrong data.
The ugly: If you forget to pass it through your monitoring, you lose visibility into who's on which version.
When it works best: Internal admin APIs or APIs with only 2-3 consumers total. Don't use this for public APIs.
What Actually Matters (More Than the Mechanism You Pick)
After trying all three approaches across different projects, here's what I've found matters more than which strategy you choose:
1. Define "Breaking Change" Before You Ship Version 1
This sounds obvious. It's not. Here's what most teams call "non-breaking" and their consumers call "breaking":
-
Renaming a field (
user_name→username). "It's just a rename!" — and every consumer's deserialization broke. -
Changing a field type (
count: "5"→count: 5). "It should have been a number all along!" — and suddenlyparseInt()throws onundefined. - Adding a required field to a request body. "We just added validation!" — and every integration that sends the old payload gets a 422.
- Changing default pagination from 20 to 50 items. "Better performance!" — and clients that assumed exactly 20 items per page now have off-by-errors.
Rule of thumb: If a consumer's code breaks without them changing anything, it's a breaking change. No exceptions.
2. Deprecation Is a Feature, Not an Afterthought
Don't just add Deprecated: true to your docs and call it done. Give consumers a migration path:
HTTP/1.1 200 OK
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Deprecation: true
Link: </api/v2/users>; rel="successor-version"
Include the Sunset header. Include a link to the new endpoint. Send deprecation notices through your developer email list. A deprecated endpoint without a clear migration path is just a time bomb.
One approach I've settled on: deprecation warnings in the response body itself. Something like:
{
"data": [...],
"meta": {
"deprecation": {
"message": "This endpoint will be removed on 2026-11-01",
"migration_guide": "https://docs.example.com/migrating-to-v2"
}
}
}
This is impossible to miss — every API consumer sees it in their response payload.
3. Version Your Error Responses Too
Here's one nobody talks about: if your error format changes between versions, consumers that parse error responses will break silently.
Version 1:
{ "error": "User not found" }
Version 2:
{ "errors": [{ "code": "USER_NOT_FOUND", "detail": "User not found" }] }
If the consumer's error handler does response.error.message, they just started getting undefined everywhere with no obvious breakage. Error format changes are breaking changes. Version them or don't change them.
What I Actually Use Now
After all the experiments, here's the setup I've converged on for most projects:
-
URL versioning for the public API surface (
/v1/,/v2/) - Explicit deprecation timeline: 6 months from announcement to shutdown
- Response-body deprecation warnings on every deprecated endpoint response
-
A
versions.jsonendpoint at/api/versionsthat returns the current version, supported versions, and deprecation timelines — so tooling can check automatically
The versions endpoint:
{
"current": "v2",
"supported": ["v2", "v1"],
"deprecated": {
"v1": {
"sunset": "2026-11-01",
"migration_guide": "https://docs.example.com/migrating-v1-to-v2"
}
}
}
It took me years to land on something this simple. I wish someone had handed me this checklist in 2022.
The Real Test
Your versioning strategy isn't tested the first time you ship it. It's tested the first time you deprecate something. That's when you find out whether your consumers actually have a migration path or whether they're just hoping you never change anything.
Build for that moment, not for the launch.
Got your own versioning horror stories? Or a setup that's working well? I'd love to hear about it in the comments.
Top comments (0)