Web Development · API Design
API Versioning Strategies That Don't Break Clients: URL, Header, and Content Negotiation
Breaking API changes are inevitable. How you handle versioning determines whether clients trust your platform or avoid integrating with it. Here's a practical comparison of URL versioning, header versioning, and content negotiation.
Anurag Verma
7 min read
Sponsored
Every API that gets used long enough will eventually need to change in a way that breaks existing clients. The question isn’t whether you’ll face this. It’s whether you have a plan for when you do.
Unversioned APIs make that plan impossible. When /api/users returns one shape today and a different shape after a refactor, you’ve broken every integration you don’t control. The longer the API has been live, the more painful this is.
Versioning is the mechanism that lets you evolve a public API without forcing clients to update on your schedule. Done right, it extends trust. Done poorly, it creates version fragmentation and maintenance debt that’s worse than no versioning at all.
What “Breaking Change” Actually Means
Not all API changes break clients. It’s worth being clear about what does and doesn’t require a new version.
Non-breaking (safe to ship anytime):
- Adding a new field to a response object
- Adding a new optional request parameter
- Adding a new endpoint
- Fixing a bug where the response was incorrect
Breaking (requires version coordination):
- Removing a field from a response
- Renaming a field
- Changing a field’s type (string → integer)
- Changing required request parameters
- Changing authentication methods
- Changing the semantics of an existing field (same name, different meaning)
A client that follows Postel’s Law (liberal in what it accepts) will survive some of the non-breaking changes even if they weren’t anticipated. But you can’t count on that. Treat field removals and renames as breaking, always.
URL Versioning
The most common approach: embed the version in the URL path.
GET /api/v1/users/42
GET /api/v2/users/42
Implementation with Express:
const express = require("express");
const app = express();
const v1Router = require("./routes/v1");
const v2Router = require("./routes/v2");
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
Or with FastAPI:
from fastapi import FastAPI
from app.api.v1 import router as v1_router
from app.api.v2 import router as v2_router
app = FastAPI()
app.include_router(v1_router, prefix="/api/v1")
app.include_router(v2_router, prefix="/api/v2")
Why most teams choose it:
- Visible. The version is in every log line, every curl command, every browser network tab.
- Easy to route. Nginx or any proxy can direct traffic to different backend handlers based on the URL prefix.
- Easy to test. You can open both versions in a browser simultaneously.
- Clients can see exactly what they’re calling without reading headers.
The criticism:
URL versioning violates REST’s “resource” principle: the resource is a user, not a versioned user. /api/v2/users/42 and /api/v1/users/42 represent the same resource. Purists argue this is wrong.
In practice, this matters less than the operational benefits. Stripe, Twilio, GitHub, and most major API providers use URL versioning. The theoretical argument doesn’t hold up against the practical reality of building and maintaining APIs at scale.
Version granularity:
Most teams version the entire API (v1, v2, v3) rather than per-resource versioning (/users/v2/42). Per-resource versioning sounds appealing but becomes a maintenance nightmare. Clients have to track which version of each resource they’re on, and documentation becomes a combinatorial explosion.
Header Versioning
The version lives in a request header, keeping URLs clean:
GET /api/users/42
API-Version: 2026-05-01
Or using the Accept header:
GET /api/users/42
Accept: application/vnd.myapi.v2+json
Implementation with FastAPI:
from fastapi import FastAPI, Header, HTTPException
from typing import Annotated
app = FastAPI()
@app.get("/api/users/{user_id}")
async def get_user(
user_id: int,
api_version: Annotated[str, Header(alias="API-Version")] = "2025-01-01",
):
if api_version >= "2026-01-01":
return get_user_v2(user_id)
else:
return get_user_v1(user_id)
What it’s good for:
- Clean URLs. Useful when you want the URL to represent the resource, not the version.
- Date-based versioning. Stripe uses this:
Stripe-Version: 2024-06-20. Clients pin to a specific date version and stay on that behavior until they explicitly upgrade. - Semantic versioning in headers is natural.
The trade-offs:
- Less visible. Debugging requires inspecting headers, not just the URL.
- Caching is harder. CDNs and proxies cache by URL by default. A versioned response in a header changes the cache key implicitly, so you need to set
Vary: API-Versionor similar to avoid serving cached responses from the wrong version. - Testing is more friction.
curl https://api.example.com/users/42needs a-Hflag. - Some clients (especially older HTTP clients and browser fetch) require extra configuration to send custom headers.
Stripe’s date-versioning model is one of the most thoughtful implementations: each API version is a dated snapshot of behavior. When you sign up for Stripe, your integration gets pinned to the current version. You opt into upgrades. This shifts the maintenance burden from Stripe to the client team, but gives clients full control over when they take breaking changes.
Content Negotiation
The Accept header in HTTP is designed for content negotiation: clients tell the server what formats they accept, and the server picks the best match:
GET /api/users/42
Accept: application/vnd.myapi.v2+json
This is the most RESTfully correct approach, but also the most complex to implement correctly.
from fastapi import FastAPI, Header, Response
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/api/users/{user_id}")
async def get_user(
user_id: int,
accept: str = Header(default="application/json"),
):
if "vnd.myapi.v2+json" in accept:
user = get_user_v2(user_id)
return JSONResponse(
content=user,
media_type="application/vnd.myapi.v2+json"
)
else:
user = get_user_v1(user_id)
return JSONResponse(
content=user,
media_type="application/json"
)
The honest assessment:
Content negotiation is the theoretically correct approach and the practically painful one. The Vary: Accept header is required to prevent caching issues. Clients that don’t set the header correctly get an unpredictable version. Error messages become harder to understand (“why did I get v1 behavior?”). Most developer tooling defaults to not showing what Accept header was sent.
GitHub API v3 used vendor media types. Very few teams adopted the style. GitHub’s own recommendation now is just to include the version in the URL or a simpler header.
Unless your API design process is deeply committed to REST constraints and your clients are sophisticated enough to use content negotiation correctly, this approach adds more complexity than it removes.
Deprecation and Sunset
Versioning is only half the problem. The other half is retiring old versions without leaving clients stranded.
The HTTP Sunset header (RFC 8594) lets you signal when a version will be decommissioned:
HTTP/1.1 200 OK
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Deprecation: Mon, 01 Jan 2026 00:00:00 GMT
Link: <https://docs.example.com/api/migration-v2>; rel="successor-version"
Clients that inspect headers can display a warning to developers. Most don’t, but the header at least creates a machine-readable signal that tooling can eventually act on.
More practically, communicate deprecation through:
- Changelog and release notes
- Email to API key holders (you do have contact emails for API consumers, right?)
- Dashboard warnings in your developer portal
- Incrementally increasing response times or error rates on deprecated versions as the deadline approaches (controversial, but gets attention)
A reasonable deprecation lifecycle for a public API: announce at least 12 months in advance, send reminders at 6 months and 1 month, shut down on the announced date. Slipping the date “for a few clients who haven’t migrated” trains clients to ignore deprecation notices.
Versioning Internal APIs
Everything above applies to public or partner APIs. For internal APIs (service to service within your own infrastructure), the trade-offs shift.
Internal APIs can often be versioned implicitly through deployment coordination: deploy the new consumer first (ignore the new field), then deploy the new producer, then remove the old field. This works when you control both sides.
Consumer-driven contract testing (Pact) is the formal version of this: consumers define what they expect, producers verify they satisfy those expectations before deploying. This catches breaking changes before they reach production without requiring explicit versioning.
Picking a Strategy
For most teams:
- Public API, small to mid-size: URL versioning. Simple, visible, well-understood.
- Public API, Stripe-style platform: Date-based header versioning. More complexity but better isolation between clients.
- Internal API: No versioning. Use consumer-driven contracts instead.
- GraphQL: Versioning is a different problem. GraphQL’s schema evolution through field deprecation handles most cases without a version bump.
Whatever you choose, document the versioning policy before you launch the API. “We version breaking changes in the URL, support major versions for 18 months after deprecation, and announce changes 12 months in advance” is a promise to clients. Make it explicit, make it in writing, and keep it.
The trust that comes from a consistent versioning policy is worth more than any particular technical approach. Clients will integrate with APIs that have well-communicated change processes, even if the API itself is imperfect.
Sponsored
More from this category
More from Web Development
CSS Anchor Positioning: Tooltips and Popovers Without JavaScript
gRPC in 2026: When to Use It Instead of REST or GraphQL
k6 Load Testing: Performance Testing Your APIs Before Users Find the Problems
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored