URL Versioning vs Header Versioning: What to Ship on a Paid API

You have one public endpoint, a handful of paying integrators, and a response shape you now regret. The next decision is where the version number lives: in the path as /v1/reports, or in a content-negotiation header as Accept: application/vnd.acme.v1+json. The internet will argue about REST purity for a thousand comments without mentioning the two things that decide it for a small commercial API — what each does to your cache hit rate, and how much support time it burns. This page resolves that. Part of the Versioning and Evolving Public APIs guide.

The headline: put the major version in the path, and reserve header-based negotiation for the small dated changes that do not deserve a whole new major version. That hybrid is what most APIs that survive a decade converge on, and it costs you almost nothing to set up on day one.

The two schemes, dimension by dimension

Path versioning encodes the contract in the resource identifier. GET /v2/reports/42 is a different URL from GET /v1/reports/42, and every layer of the stack — router, CDN, proxy log, browser address bar, someone's bookmarked curl command — treats them as two distinct things without being told anything.

Header versioning keeps one URL and asks the client to state which representation it wants. The purist argument is correct: /reports/42 identifies a report, and v1 or v2 is a serialization of that same report, so the version belongs in content negotiation. The catch is that the internet's plumbing was built to key on URLs, and moving the discriminator into a header makes you responsible for teaching every one of those layers about it.

Path versioning versus header versioning by dimension A five-row comparison matrix covering cache keys, manual testing, generated docs, log triage and the cost of adding a second version under each scheme. Where the version number actually shows up Dimension Path: /v1/reports Header: Accept vnd CDN cache key Free, in the URL Needs Vary: Accept Manual curl test Paste URL and go Extra -H every time Generated docs One schema each Hand-built branches Access-log triage Version is visible Log the header first Cost of adding v2 Copy one router Branch in handlers Header versioning wins on purity; path versioning wins on plumbing

In FastAPI the path scheme is almost embarrassingly cheap. Two routers, two prefixes, one line each to mount them — and because each router owns its own response models, your OpenAPI documentation stays truthful for free.

Python
import os
from fastapi import APIRouter, FastAPI

app = FastAPI(title=os.getenv("API_TITLE", "Reports API"))

v1 = APIRouter(prefix="/v1", tags=["v1"])
v2 = APIRouter(prefix="/v2", tags=["v2"])


async def load_report(report_id: str) -> dict[str, object]:
    """Stand-in for your real query layer."""
    return {"id": report_id, "total_cents": 1299, "currency": "usd"}


@v1.get("/reports/{report_id}")
async def get_report_v1(report_id: str) -> dict[str, object]:
    row = await load_report(report_id)
    return {"id": row["id"], "total": row["total_cents"] / 100}


@v2.get("/reports/{report_id}")
async def get_report_v2(report_id: str) -> dict[str, object]:
    row = await load_report(report_id)
    return {
        "id": row["id"],
        "total": {"amount": row["total_cents"], "currency": row["currency"]},
    }


app.include_router(v1)
app.include_router(v2)

The header scheme costs more code before it does anything useful, because you have to parse the negotiation yourself, reject unknown versions with a 406, and remember to advertise what you did:

Python
import os
import re
from fastapi import Depends, FastAPI, Header, HTTPException, Response

VENDOR = os.getenv("API_VENDOR", "acme")
DEFAULT_VERSION = int(os.getenv("API_DEFAULT_VERSION", "1"))
SUPPORTED = {1, 2}
ACCEPT_RE = re.compile(rf"application/vnd\.{re.escape(VENDOR)}\.v(\d+)\+json")

app = FastAPI(title=os.getenv("API_TITLE", "Reports API"))


async def negotiated_version(accept: str | None = Header(default=None)) -> int:
    if not accept:
        return DEFAULT_VERSION
    for candidate in accept.split(","):
        found = ACCEPT_RE.search(candidate.strip())
        if not found:
            continue
        version = int(found.group(1))
        if version not in SUPPORTED:
            raise HTTPException(406, f"unsupported version v{version}")
        return version
    return DEFAULT_VERSION


@app.get("/reports/{report_id}")
async def get_report(
    report_id: str,
    response: Response,
    version: int = Depends(negotiated_version),
) -> dict[str, object]:
    response.headers["Vary"] = "Accept"
    response.headers["Content-Type"] = f"application/vnd.{VENDOR}.v{version}+json"
    match version:
        case 1:
            return {"id": report_id, "total": 12.99}
        case _:
            return {"id": report_id, "total": {"amount": 1299, "currency": "usd"}}

That Vary: Accept line is not optional garnish. Forget it and every caching layer between you and your customer will serve a v1 body to a v2 client, which is the single nastiest failure mode in this entire debate because it only happens to some users, only under load, and never on your laptop.

Cacheability: the dimension that costs real money

A path-versioned response is trivially cacheable at the edge. /v1/reports/42 and /v2/reports/42 are separate cache objects with separate TTLs, and a CDN or reverse proxy stores and serves them without configuration. If you already run Redis-backed response caching, the version simply becomes part of the key you were building anyway.

Header versioning forces you onto Vary, and Vary is a blunt instrument: it stores one variant per distinct header value seen, not per version you support. Clients send Accept: */*, Accept: application/json, Accept: application/vnd.acme.v2+json, application/json;q=0.9, and every SDK's slightly different ordering of the same list. Each spelling becomes a separate cache entry even when three of them resolve to the same version. Hit rate collapses, origin traffic climbs, and your compute bill rises for a reason nobody will find in a profiler.

Edge cache behaviour under each versioning scheme Path versioning produces two clean cache objects keyed by URL, while Accept-header versioning fragments one URL into many Vary variants and drops the hit rate. What the edge stores for the same report PATH SCHEME Clients Edge cache key /v1/reports/42 — hit key /v2/reports/42 — hit 2 objects, roughly 90 percent hit rate HEADER SCHEME Clients Edge cache Vary: Accept /reports/42 + vnd.acme.v1+json /reports/42 + application/json /reports/42 + star slash star /reports/42 + v2, json q=0.9 4 objects for 2 versions, hit rate falls toward 50 percent Drop Vary by accident and the edge serves a v1 body to a v2 client

If you go the header route anyway, normalise before you cache: resolve the header to an integer at the edge of your application and build the key from that integer, never from raw header text. That keeps your Redis layer sane even when the CDN in front of you is not.

Python
import hashlib
import os
import redis.asyncio as redis

client = redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
NAMESPACE = os.getenv("API_CACHE_NAMESPACE", "reports")
TTL_SECONDS = int(os.getenv("API_CACHE_TTL", "60"))


def cache_key(path: str, version: int, tier: str) -> str:
    """One key per (resource, resolved version, plan tier) — never per raw header."""
    raw = f"{NAMESPACE}:v{version}:{tier}:{path}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]


async def cached_body(path: str, version: int, tier: str) -> str | None:
    return await client.get(cache_key(path, version, tier))


async def store_body(path: str, version: int, tier: str, body: str) -> None:
    await client.set(cache_key(path, version, tier), body, ex=TTL_SECONDS)

That is the same discipline that keeps cache invalidation strategies tractable: one canonical key shape, computed in one place.

Client ergonomics and the support load they generate

Your integrators are not a standards committee. They are a solo founder wiring your API into a Django admin at midnight, an agency developer copying whatever is in your quickstart, and an ops person debugging a broken sync from a terminal. All three benefit from a version that survives being copied and pasted.

With path versioning, a support conversation is a URL. Someone sends you https://api.example.com/v1/reports/42, you paste it into your own terminal, and you see exactly what they saw. With header versioning the URL alone is ambiguous: you have to ask what they sent in Accept, they may not know, their HTTP client may rewrite it, and a proxy in between may have stripped or reordered it. That is a five-message exchange instead of one, and at indie scale support time is your most expensive input.

Header versioning also breaks the trivial cases. You cannot open a versioned endpoint in a browser or hand a customer a link. Webhook receivers, spreadsheet connectors and no-code tools mostly provide no way to set a custom Accept value. If any part of your product is discoverable by pasting a URL — and it should be, because that is how a developer portal converts a curious visitor into a trial — the path scheme is the one that works.

Where header versioning genuinely shines is in a well-built SDK, because the version becomes a client-level default nobody has to think about per call:

Python
import os
import httpx

BASE_URL = os.environ["API_BASE_URL"]
VENDOR = os.getenv("API_VENDOR", "acme")
VERSION = os.getenv("API_VERSION", "2")


def build_client() -> httpx.AsyncClient:
    """Pin the version once; every request inherits it."""
    return httpx.AsyncClient(
        base_url=BASE_URL,
        timeout=httpx.Timeout(10.0, connect=5.0),
        headers={
            "Accept": f"application/vnd.{VENDOR}.v{VERSION}+json",
            "Authorization": f"Bearer {os.environ['API_TOKEN']}",
        },
    )


async def fetch_report(report_id: str) -> dict[str, object]:
    async with build_client() as client:
        response = await client.get(f"/reports/{report_id}")
        response.raise_for_status()
        return response.json()

The same one-line default works for a path-versioned API — you put /v2 in base_url instead. So the SDK argument is a wash, and the difference only shows up the moment someone works outside the SDK. On a small API that happens constantly.

Routing and documentation cost inside FastAPI

Path versioning maps onto FastAPI's structure directly. Each version is a router, or better a whole sub-application mounted under its prefix, so v1 pins an old Pydantic model and v2 pins the new one without either importing the other. When you finally delete v1, you delete a directory and one include_router line. That deletability is worth more than it sounds: old versions linger for years mostly because nobody can tell which code belongs to them.

Header versioning puts the branch inside the handler. That is fine for two versions of one field. It stops being fine when v2 renames a query parameter, drops an endpoint and changes the pagination envelope, because now every handler carries a match statement, your response model is a union, and the generated OpenAPI schema can only describe one branch honestly. You end up hand-maintaining docs — the exact chore FastAPI was supposed to delete from your week.

How a request reaches handler code under each scheme Path versioning splits at the router with one OpenAPI schema per version, while header versioning routes to a single handler that branches internally on the negotiated version. Where the branch lives in your code Path scheme: branch at the router Header scheme: branch in the handler GET /v2/reports GET /reports v1 router v2 router Accept dependency one handler, match v v1 models v2 models Two honest OpenAPI documents delete a folder to retire v1 One document, union responses retiring v1 means editing handlers Deletability is the difference that shows up two years later

There is a middle road. Keep the major version in the path and use a dated header — X-Api-Version: 2026-07-23, the shape Stripe popularised — for the tiny behaviour changes that would otherwise force a major bump. Existing accounts pin the date they signed up on, new accounts get today's, and you ship small breaking tweaks continuously without ever minting /v3. It costs you per-account version storage and one compatibility transform per dated change, but it is the only scheme that scales past a handful of majors.

When to choose each

Choose path versioning when your API is public, sold to strangers, cached at the edge, documented with generated OpenAPI, or consumed by anything that cannot set custom headers. That covers essentially every commercial API under a few million requests a month. It is also the right default for a team of one or two, because it makes the version legible in logs, dashboards and usage analytics with no extra instrumentation.

Choose header versioning when the clients are exclusively your own SDKs, the responses are uncacheable and user-specific anyway, and someone owns hand-written documentation. Internal service-to-service APIs fit this well, as does an API whose changes are frequent and small rather than rare and structural — precisely the dated-header case.

Decision tree for picking a versioning scheme Four questions about public clients, edge caching, generated docs and change frequency lead to path versioning, header versioning, or the hybrid of path majors plus a dated header. Answer four questions, then stop deliberating Do strangers call this API directly? no yes Is anything cached at the edge? Ship /v1 in the path no yes Docs written by hand? Path wins on cache no yes Path, keep docs honest Accept header is viable Changing shape monthly? Then add the hybrid: major in path date in a header Most paid APIs under a few million requests a month land on the path branch

Avoid header versioning if you cannot guarantee Vary is set everywhere, if any CDN or platform router sits in front of you with default caching rules you do not fully control, or if you sell to non-technical buyers who will paste URLs into tools you have never heard of. Avoid path versioning only if minting a new major version is something you expect to do more than about once a year — at that cadence, the copy-a-router move stops being cheap and the maintenance surface multiplies.

Migrating an unversioned API to /v1

You shipped /reports with no version at all. Here is the smallest safe path to a versioned contract without breaking the customers you already have.

  1. Mount your existing routes under /v1 while leaving the unversioned paths working. Nothing changes for anyone yet.
  2. Add middleware that rewrites unversioned requests to the default version and stamps a header on the response, so you can measure who is still on the legacy path.
  3. Ship it, then watch your logs for a full billing cycle. You want unversioned request counts per account, not a global total.
  4. Update your quickstart, SDK defaults and portal examples to the versioned URLs. Most traffic moves on its own as clients upgrade.
  5. Email the remaining accounts the exact URL change and attach a sunset date. Deprecating an endpoint without breaking customers covers the headers and timeline that make it stick.
  6. Keep the rewrite middleware forever. It costs microseconds, and it means an old integration never 404s for a reason the customer cannot understand.
Python
import os
from fastapi import FastAPI, Request

app = FastAPI(title=os.getenv("API_TITLE", "Reports API"))
DEFAULT_PREFIX = os.getenv("API_DEFAULT_PREFIX", "/v1")
KNOWN_PREFIXES = tuple(os.getenv("API_PREFIXES", "/v1,/v2").split(","))


@app.middleware("http")
async def pin_legacy_requests(request: Request, call_next):
    """Route unversioned paths to the default version, and mark them."""
    path = request.url.path
    legacy = not path.startswith(KNOWN_PREFIXES) and not path.startswith("/docs")
    if legacy:
        request.scope["path"] = f"{DEFAULT_PREFIX}{path}"
        request.scope["raw_path"] = request.scope["path"].encode()
    response = await call_next(request)
    if legacy:
        response.headers["X-Api-Version-Source"] = "implicit-default"
    return response

Rewriting request.scope["path"] before routing is the trick that keeps this to eight lines. Cover it with a test that asserts both /reports/42 and /v1/reports/42 return identical bodies — pytest against the real app makes that a five-line case, and it is the test that stops a future refactor silently 404ing your oldest customers.

Builder verdict

Ship path versioning. /v1 in the URL wins on every axis that costs a small commercial API real money: the edge caches it for free, support conversations resolve in one message instead of five, generated docs stay accurate without hand-editing, retiring a version is a directory deletion, and every log line tells you which contract a request used without extra instrumentation. Header versioning is the more elegant reading of HTTP, and if correctness were the only thing at stake it would win — but the elegance is invisible to your customers and the plumbing tax is not. A collapsed cache hit rate on an unremarkable Tuesday costs more than resource-identifier purity is worth, and it is a number you will struggle to attribute.

The refinement worth adopting is the hybrid: majors in the path, small dated changes behind a version header pinned per account. Start with the path only, because it takes ten minutes. Add the dated header the first time you catch yourself wanting /v2 for a single field rename — that is the signal you have outgrown majors alone, and it usually arrives around the time you have enough revenue to justify the compatibility layer it demands.

FAQ

Does path versioning actually reduce my hosting bill? Indirectly but measurably: edge and Redis caching work with no configuration, so a cacheable endpoint at 90 percent hit rate reaches origin a tenth as often. On a small API that is the difference between one container and three — tens of dollars a month, plus the p95 improvement that stops customers opening tickets. Put a number on it with cost per API request.

How risky is switching schemes after launch? Switching from headers to paths is low risk, because you can serve both simultaneously — mount the routers under prefixes and keep the negotiation dependency as a fallback for the old clients. Going the other way is worse: URLs live in customers' code, cron jobs and no-code tools you cannot see, so you can never fully retire a path. Decide before your second paying integrator and you avoid the question entirely.

Do I need a new major version to add a field? No. Adding an optional field to a response is additive and safe for any client that parses JSON properly, and a version bump for it is a self-inflicted maintenance cost. Reserve majors for removals, renames, type changes and semantic changes to existing fields. If your customers break on new fields, the fix is a note in your docs telling them to ignore unknown keys, not a /v2.

How many versions can a one-person API afford to run? Two, plus a sunset date on the older one. Every live version multiplies your test matrix, your support surface and the odds a security fix gets backported by hand. If you are running three, pick the least-used one, publish a ninety-day sunset, and spend the reclaimed hours on what customers actually pay for.

What breaks if I forget the Vary header? Cross-contamination: a shared cache stores the first response it sees for /reports/42 and serves it to everyone, so a v2 client receives a v1 body. It fails intermittently, only under real traffic, and only for some users — the hardest possible bug to reproduce. If you use header versioning, add a test that asserts Vary: Accept appears on every cacheable response, and treat a missing one as a release blocker.

Same track:

Other tracks: