Skip to main content
Architecture

API Versioning: URL, Header, Content Negotiation — Pick One and Commit

Ravinder··10 min read
ArchitectureAPI DesignVersioningREST
Share:
API Versioning: URL, Header, Content Negotiation — Pick One and Commit

Every team I have ever consulted eventually arrives at the same uncomfortable meeting. Six months into a product, someone says "we need to break the API" and the room goes quiet. How you handle that moment depends almost entirely on a decision you made — or failed to make — at the start: your versioning strategy.

Versioning is not a feature. It is a contract with everyone who has already integrated against you. Breaking it without a clear strategy means broken clients, angry partners, and an engineering team spending half its time writing compatibility shims instead of shipping product.

The three strategies that actually see production use are URL versioning, header versioning, and content negotiation. Each is defensible. Each has failure modes. This post walks through all three, tells you when each wins, and gives you the deprecation machinery to back up whichever choice you make.

Why "We'll Figure It Out Later" Never Works

The worst versioning strategy is none. I have seen codebases where the "version" was just a flag — ?legacy=true, or a boolean in the JSON body, or a comment in the Confluence page that said "this endpoint is v2 now." Every workaround becomes load-bearing. Every client ends up coupled to internal implementation details. Six months later you have a versioning system more complicated than any of the three named strategies, with none of the tooling support.

Pick a strategy at the start, document it in your API governance rules, and enforce it in code review. The specific choice matters less than the commitment.

Strategy One: URL Versioning

The simplest approach puts the version directly in the path.

GET /v1/users/123
GET /v2/users/123

This is what Stripe, Twilio, and most of the APIs developers actually like use. There is a reason for that. URL versioning is:

  • Obvious. Developers can see the version in logs, browser tabs, curl output, and Postman collections without any extra tooling.
  • Cacheable. Reverse proxies and CDNs cache by URL. v1 and v2 are separate cache keys automatically.
  • Linkable. You can bookmark, share, and document a URL that points to a specific version with zero ambiguity.
  • Testable. Hitting /v2/ in a browser or Postman needs no custom headers.

The downside is philosophical: purists argue that the URL should identify a resource, not a version of the API that serves that resource. /v2/users/123 is not a different resource than /v1/users/123. It is the same user, described differently. This is a real argument but it has not stopped most of the industry from choosing URL versioning anyway.

The operational downside is more practical: you now maintain multiple route trees. v1 and v2 may both need security patches, bug fixes, and monitoring. That is real ongoing cost.

Strategy Two: Header Versioning

Header versioning keeps the URL clean and puts the version in a custom request header.

GET /users/123
Accept-Version: v2

Or equivalently, some teams use a vendor-prefixed header:

GET /users/123
X-API-Version: 2024-11-01

GitHub's API uses a date-based header version scheme (X-GitHub-Api-Version: 2022-11-28). Salesforce uses headers. Many internal enterprise APIs use them because URL cleanliness matters in large internal systems where URLs appear in audit logs that were never designed to hold version segments.

The tradeoffs are real:

Pros:

  • Resource URL stays stable. /users/123 is always users.
  • You can evolve the versioning scheme independently of the routing tree.
  • Easier to make version the default for new clients while staying backward compatible.

Cons:

  • Not cacheable by default. You must configure Vary: Accept-Version on every endpoint, and most CDNs handle Vary poorly.
  • Not visible in a browser address bar, curl output, or logs without extra instrumentation.
  • Clients forget to set the header. You will spend a lot of time debugging requests that silently fell through to the default version.
  • OpenAPI / Swagger has no standard way to document header-based versions. Your SDK generators will fight you.

The forgotten-header problem is the one that kills header versioning in practice. At one company I worked with, they shipped header versioning and then spent three months triaging support tickets where partners swore they were on v2 but were actually getting v1 responses because their HTTP client library was stripping custom headers through a proxy. URL versioning does not have this class of problem.

Strategy Three: Content Negotiation

Content negotiation puts the version inside the Accept header as a media type parameter.

GET /users/123
Accept: application/vnd.myapi.v2+json

This is the most HTTP-correct approach. It follows the intent of RFC 7231. The GitHub API used this for years. It is genuinely elegant on paper.

In practice, it is the least used because:

  • Media type parameters are not supported by most API testing tools out of the box.
  • Framework routing by Accept header requires explicit middleware. Almost no framework ships this natively.
  • Logging and debugging become painful. Nobody reads the Accept header in a curl log.
  • Client libraries frequently mangle or strip Accept headers in ways that are hard to debug.

Content negotiation is the right answer if you are building a hypermedia-driven API with a sophisticated client team and strong control over both sides of the wire. For a product API serving third-party developers, it is a footgun.

Comparing the Three

flowchart LR subgraph URL["URL Versioning"] U1["/v1/users/123"] U2["/v2/users/123"] end subgraph Header["Header Versioning"] H1["GET /users/123\nAccept-Version: v1"] H2["GET /users/123\nAccept-Version: v2"] end subgraph Content["Content Negotiation"] C1["GET /users/123\nAccept: vnd.api.v1+json"] C2["GET /users/123\nAccept: vnd.api.v2+json"] end URL -->|"Best for: external APIs\nPublic developer products"| Done1[Recommended] Header -->|"Best for: internal APIs\nControlled client environment"| Done2[Use with care] Content -->|"Best for: hypermedia APIs\nStrict REST compliance"| Done3[Niche use]
Dimension URL Header Content-Negotiation
Discoverability High Low Very Low
Cacheability Native Requires Vary Requires Vary
Browser/curl testable Yes No No
OpenAPI support First-class Partial Poor
Purist REST compliance Low Medium High

Deprecation Timelines That People Actually Follow

Choosing a strategy is step one. Deprecation mechanics are step two, and teams almost always skip it.

A working deprecation process has three components:

1. Sunset headers on every deprecated response.

RFC 8594 defines the Sunset header. Use it.

HTTP/1.1 200 OK
Sunset: Sat, 01 Feb 2026 00:00:00 GMT
Deprecation: Mon, 01 Jul 2025 00:00:00 GMT
Link: <https://docs.myapi.com/migration/v1-to-v2>; rel="deprecation"

Every response from a deprecated version should carry these headers. Client developers who instrument their HTTP clients — and the good ones do — will see these in logs and act on them.

2. Progressive friction before hard removal.

Do not go from "works fine" to "410 Gone" overnight. Use a three-phase approach:

Phase 1 (Month 0-3):   Sunset header added. Documentation updated.
Phase 2 (Month 3-6):   Deprecation warnings emitted to API logs.
                        Email to registered consumers.
Phase 3 (Month 6+):    5% of requests receive 410. Increased to 25%, then 100%.

The traffic-based rollout in Phase 3 gives laggard clients a forcing function without a hard cliff.

3. Traffic-based sunset enforcement.

import random
from datetime import datetime
 
DEPRECATED_VERSIONS = {
    "v1": {
        "sunset_date": datetime(2026, 2, 1),
        "enforcement_pct": 0.05,  # 5% rejection rate during soft sunset
    }
}
 
def version_middleware(request, version: str):
    if version not in DEPRECATED_VERSIONS:
        return None  # Not deprecated, pass through
 
    config = DEPRECATED_VERSIONS[version]
    now = datetime.utcnow()
 
    if now >= config["sunset_date"]:
        return {"error": "API version sunset", "status": 410}
 
    # Soft sunset: reject a percentage of traffic to force action
    if random.random() < config["enforcement_pct"]:
        return {
            "error": "This API version is deprecated and will be removed on "
                     f"{config['sunset_date'].strftime('%Y-%m-%d')}. "
                     "See https://docs.myapi.com/migration",
            "status": 410,
        }
 
    return None  # Let the request through with Sunset headers

The Date-Based Version Scheme

If you choose header versioning, consider date-based versions rather than v1, v2, v3. GitHub made this choice for a reason.

Date-based versions (2024-11-01) communicate both the version and its age. A client running 2021-03-01 in 2025 is obviously outdated. v1 is less clear — is v1 from last year or three years ago?

Date versioning also lets you ship multiple non-breaking changes between major versions without the awkward question of "is this a v1.1 or a v2?"

The downside is that dates are harder to sort programmatically and harder to reason about in code. Integer versions have their charms.

Header Pitfalls Catalog

For teams committed to header versioning, here is the failure catalog:

Proxy stripping. Enterprise networks and API gateways sometimes strip unknown custom headers. If you use X-API-Version, document this explicitly and test through every proxy layer your customers might use.

Missing Vary response header. Without Vary: X-API-Version, CDNs and reverse proxies will serve v1 cached responses to v2 clients. This is a silent bug that causes hours of debugging.

Default version ambiguity. What happens if no version header is sent? You need a documented policy: reject with 400, serve the latest version, or serve the oldest stable version. "It depends" is not a policy.

SDK generator failures. OpenAPI's parameters section can specify header parameters but most code generators do not know to treat them as version selectors. Your auto-generated SDK will not handle version switching correctly. Plan for manual SDK adjustment.

Real-World Choices and Why

Stripe: URL versioning (/v1/) with date-pinned account-level versions. Every API key has a version pinned at creation. Breaking changes are released at new date versions but existing keys stay on their pinned version. This is the gold standard for public APIs.

GitHub REST API: URL versioning for major versions, date-based header for fine-grained changes. Best of both approaches, but requires managing two version axes simultaneously.

Kubernetes: API groups in URL (/apis/apps/v1/deployments), with stability levels (alpha, beta, stable) embedded in the path segment. Bespoke but effective for a platform that needs to graduate API stability over time.

Twilio: URL versioning (/2010-04-01/Accounts/). Date in the path segment serves as the "major version." Has not changed the root version since 2010 because backward compatibility is an explicit product guarantee.

What I Actually Recommend

For a product API serving external developers: URL versioning. The developer experience advantages are too significant to sacrifice for philosophical purity. Put the version in the path, use integer major versions, and plan for at least 12 months of parallel support between versions.

For an internal API in a controlled deployment environment: header versioning with strict enforcement of the Vary header and a documented default version policy.

For a hypermedia API or a standards-compliant research project: content negotiation. Accept that you are optimizing for correctness over convenience.

Whatever you choose, make it a written rule, enforce it in CI, and implement the sunset headers before the first version ships.

Key Takeaways

  • URL versioning wins on developer experience: it is visible, cacheable, and testable without custom tooling.
  • Header versioning requires strict Vary header management and proxy-layer testing to avoid silent cache poisoning.
  • Content negotiation is the most HTTP-correct approach but has the worst tooling support and debugging experience.
  • Deprecation needs three phases: announcement, progressive friction, and hard removal — not a hard cliff.
  • The Sunset and Deprecation response headers (RFC 8594) give clients machine-readable signals; use them from day one.
  • Date-based version identifiers communicate age as well as sequence, which is useful for header and content-negotiation schemes.