The Practical Developer

API Versioning Strategies: URL, Header, or Query - And How To Retire The Old One

Every API eventually breaks its clients. The difference between a controlled upgrade and a fire drill is how you version it. Here is the real trade-off between URL, header, and query-parameter versioning, the backward-compatibility rules that actually hold, and the sunset playbook that keeps mobile clients from breaking at 2 a.m.

An abstract network of connected nodes, the same visual you stare at when your v2 API silently broke your oldest mobile client

You rename a field from user_name to username because the rest of the codebase uses camelCase. The CI passes. The deploy goes green. Two hours later, support tickets start rolling in: the iOS app crashes on launch, the partner integration is returning nulls, and the billing pipeline just charged somebody twice because it parsed a renamed field as missing.

The three lines of rename cost more than the feature that made you rename it. And the root cause is not “we should have caught it in testing.” The root cause is that you changed a contract that other code depended on, and you gave those clients no way to upgrade at their own pace.

API versioning is the answer, but most teams pick a strategy by copying whatever the SaaS provider they admire uses. That is not a strategy. It is cargo-culting. URL versioning (/v1/users), header versioning (Accept: application/vnd.example.v2+json), and query-parameter versioning (/users?version=2) each impose different costs on your producers, your consumers, and your infrastructure. The right choice depends on who your clients are and how you deploy.

This post walks through each strategy with real trade-offs, gives you the backward-compatibility rules that prevent “versioning” from being a false promise, and ends with a deprecation playbook you can copy into your next project.

The minimum viable versioning decision

Before you pick a strategy, you need two things that have nothing to do with URLs or headers:

1. A written policy for what counts as a breaking change. Without this, versioning is theater. Your team will disagree on whether adding a field is breaking (it is not, for JSON APIs), whether reordering enum values is breaking (it is, for serialized code), and whether changing a 404 to a 403 is breaking (it is, for clients that parse status codes). Write it down. Enforce it in code review.

2. A way to run multiple versions simultaneously. You cannot version your API if your deployment model does not support running two versions of the same endpoint at the same time. That sounds obvious. It is also the thing teams discover six months in, when their monolith cannot serve /v1/users and /v2/users from the same process because the routing is hardcoded.

The three strategies

URL path versioning

GET /v1/users/42
GET /v2/users/42

This is the most common strategy, used by Stripe, Twilio, GitHub, and most public APIs. The version is part of the path. The router dispatches to different handler code based on that prefix.

What it costs you:

  • You now maintain N copies of every endpoint. If you have six versions live (unusual but possible with slow-migrating enterprise clients), you have six handlers, six sets of tests, six OpenAPI specs. Refactoring the internal logic means propagating changes across all of them or letting them diverge and hoping you remember which version has which behavior.
  • URL pollution. The path is the cleanest part of a RESTful design, and versioning it makes every route one level deeper.
  • Code organization can decay into copy-paste inheritance. Version 2 copies version 1’s handler and tweaks one field. Version 3 copies version 2 and tweaks another. Every version has the same bug, fixed in different ways.

What it buys you:

  • Maximum discoverability. Any client, any proxy, any curl command can see the version immediately. No special headers to set, no middleware to inspect.
  • Easy caching. A CDN caches /v1/users/42 and /v2/users/42 separately. The cache key is the URL.
  • Easy debugging. The URL tells you exactly which version ran. Logs, metrics, tracing all get the version for free.

When to use it: Public APIs with external clients that you cannot upgrade unilaterally, especially mobile apps whose users do not update immediately. The discoverability and caching wins matter more than the code duplication cost.

Header versioning

Accept: application/vnd.example.v2+json
# or
X-API-Version: 2

The version is negotiated through content-type headers or a custom header. The URL stays clean.

What it costs you:

  • Invisible to everyone. A developer hitting the API with curl gets v1 (the default) unless they know to set the header. Documentation becomes critical.
  • Poor caching. Most CDNs and reverse proxies do not inspect Accept headers by default. You need custom Vary headers, which fragment the cache or bypass it entirely.
  • Debugging friction. When a client reports a problem, the first question is “which version did you call?” and the answer is usually “I do not know, whatever the default is.” You have to dig through access logs or ask the client to send the header.
  • Testing complexity. Every integration test now needs to send a header instead of a URL. It is a small difference, but it compounds across hundreds of tests.

What it buys you:

  • Clean URLs. The resource path stays stable. If you believe RESTfully that /users/42 is a noun whose representation should be negotiated, this is the theoretically pure approach.
  • No path routing complexity. The same handler runs for all versions; it inspects the header and branches internally. This forces you toward shared internal code (a single handler with version-specific transforms) instead of copying handlers.
  • Backward-compatible defaults. New clients set the version header; old clients get the default. Nobody’s URL breaks.

When to use it: Internal APIs where you control both the client and the server, and where you care about URL aesthetics. Also useful for APIs where the client is a browser and you want to keep URLs bookmarkable across versions.

Do not use the custom header approach (X-API-Version) for public APIs. It does not follow HTTP semantics and it breaks in unexpected ways with proxies and gateways that strip unknown headers. Use Accept with a vendor media type if you go this route.

Query-parameter versioning

GET /users/42?version=2

The version is a query parameter. It is the easiest to implement (one middleware check) and the hardest to maintain.

What it costs you:

  • Cache poisoning. Query parameters are treated as part of the cache key, but clients that omit ?version=2 may get a cached response meant for v2, or vice versa, depending on your cache configuration.
  • Proxy confusion. Load balancers and API gateways often split traffic based on URL paths. Query parameters are invisible to most routing rules.
  • It encourages query-string pollution. Once “version” goes in the query string, every other configuration parameter follows, and your API becomes a grab bag of optional flags.
  • Poor documentation ergonomics. Developers expect versioning to be visible in the URL. Hiding it in a parameter makes the default version (no parameter) a hidden behavior.

What it buys you:

  • Dead simple to implement. One piece of middleware reads the query string and sets req.apiVersion. No routing changes, no header inspection.
  • Easy to default. No version = v1. Add ?version=2 to opt into the new behavior. Great for gradual rollouts of internal migration.
  • No URL pollution of the path. But at the cost of making the version invisible.

When to use it: Never for public APIs. Use it only for internal, short-lived version transitions where you are migrating one service at a time and plan to remove the parameter after 90 days. It is a migration tool, not a versioning strategy.

The backward-compatibility rules that actually hold

Regardless of which strategy you pick, your version promise is only as good as your backward-compatibility rules. Here are the rules that survive production.

Adding a field is not breaking. An extra key in a JSON response should be ignored by any well-written client. If it is not, that is a client bug, not an API break. Never bump a version for adding a field.

Changing a field’s type is breaking. String to number, number to string, null to array. Always a version bump.

Removing a field is breaking. Unless you have telemetry proving zero clients depend on it. You rarely have that telemetry. Assume every field has consumers. Bump the version.

Changing the order of enum values is breaking. Clients that use numeric index instead of the string value exist. You will never know about them until they break. Freeze enum order per version or version the enum.

Adding a new endpoint is not breaking. Existing clients do not call it. The API surface expands. No version bump.

Changing status codes is breaking. A client that parses 201 Created may not handle 200 OK. A client that expects 404 Not found for missing resources may not handle 403 Forbidden. Status codes are part of the contract.

Changing error shapes is breaking. If your v1 errors look like { "error": "message" } and your “backward-compatible” v2 errors look like { "code": "NOT_FOUND", "detail": "message" }, existing clients that parse error will silently swallow errors. Version bump.

Write these rules into your OpenAPI spec. Enforce them with a diff check in CI:

# Pseudo-code: diff the spec, fail on breaking changes
npx openapi-diff --old spec-v1.yaml --new spec-v2.yaml --fail-on-breaking

The OpenAPI Specification Diff tool or OasDiff can automate this. Run it in CI on every PR that touches the spec.

Internal branching: the pattern that saves your handlers

The biggest pain of versioning is maintaining parallel handlers. The fix is not to eliminate duplication entirely (some is inevitable) but to confine it to a thin transform layer.

request
  |
  v
router ──→ version resolver ──→ business logic (shared)
                                     |
                                     v
                               response formatter (version-specific)

The business logic runs once. The version-specific part is only the response shape and any input parsing differences. If you find yourself copying an entire handler to change one field, you have not versioned your API; you have forked your codebase.

// Bad: forked handlers
async function getUserV1(id: string) {
  const user = await db.findUser(id);
  return { user_name: user.name, email_addr: user.email };
}

async function getUserV2(id: string) {
  const user = await db.findUser(id);
  return { username: user.name, email: user.email, role: user.role };
}

// Better: shared logic, versioned transform
async function getUser(id: string, version: 1 | 2) {
  const user = await db.findUser(id);
  const response = { name: user.name, email: user.email, role: user.role };

  if (version === 1) {
    return {
      user_name: response.name,
      email_addr: response.email,
    };
  }

  return {
    username: response.name,
    email: response.email,
    role: response.role,
  };
}

This pattern keeps the database query and business rules in one place. The version switch is a translation layer at the boundary. When you eventually drop v1, you delete the if (version === 1) branch and rename the response keys. One file changes, not a whole handler.

The sunset playbook

Versioning without sunsetting is just accumulating cruft. Every major API I have worked on that is older than five years has at least three versions live, nobody knows which clients use the oldest one, and the team is afraid to remove it.

Follow this playbook instead.

Step 1: Track usage. Every response includes a version identifier. Log it. Aggregate by client ID. Know which clients call which version, and when they last called it.

// Response header
api-supported-versions: 1, 2, 3
api-deprecated-versions: 1
// Middleware to log usage
app.use((req, res, next) => {
  res.on('finish', () => {
    logger.info({
      path: req.path,
      version: req.apiVersion,
      clientId: req.headers['x-client-id'] ?? 'unknown',
      status: res.statusCode,
    });
  });
  next();
});

Step 2: Announce deprecation. Set a sunset date at least six months out. Add a Sunset header to responses for the deprecated version. Add a Warning header with a descriptive message.

Sunset: Sat, 01 Dec 2026 23:59:59 GMT
Warning: 299 - "v1 is deprecated. Migrate to v2. See https://docs.example.com/migration-guide"

Step 3: Surface migration guides. For every breaking change between v1 and v2, write exactly one migration step. “Rename user_name to username” is one step. “Restructure your entire request flow” is multiple steps, which means you are doing too much in one version bump. Break it into v2 and v3.

Step 4: Freeze the deprecated version. No new features. Security patches only. Document that the version is in maintenance mode so teams do not accidentally build on top of it.

Step 5: Drop it when usage hits zero. Not when the sunset date passes. When usage hits zero. If you set a date and usage is not zero, you either extend the date or you are breaking clients that chose not to migrate. The sunset date is for you to plan the work, not for the clients to migrate by. They will migrate when they are ready.

I have never seen an API version get to zero usage on the scheduled date. Plan for a 12-18 month overlap window between releasing vN+1 and fully decommissioning vN.

What this looks like in practice

Here is the full wiring for URL-based versioning in a Fastify app:

import Fastify from 'fastify';

const app = Fastify();

// Shared business logic
async function getUserData(id: string) {
  return db.findUser(id);
}

// v1 handler
app.get('/v1/users/:id', async (req, reply) => {
  const user = await getUserData(req.params.id);
  return { user_name: user.name, email_addr: user.email };
});

// v2 handler
app.get('/v2/users/:id', async (req, reply) => {
  const user = await getUserData(req.params.id);
  return { username: user.name, email: user.email, role: user.role };
});

// Deprecation middleware
app.addHook('onSend', (req, reply, payload, done) => {
  if (req.url.startsWith('/v1/')) {
    reply.header('Sunset', 'Sat, 01 Dec 2026 23:59:59 GMT');
    reply.header('Warning', '299 - "v1 is deprecated. Migrate to v2."');
    reply.header('api-supported-versions', '1, 2');
    reply.header('api-deprecated-versions', '1');
  }
  done();
});

For header-based versioning in Express:

import express from 'express';

const app = express();

app.get('/users/:id', (req, res) => {
  const version = parseVersion(req.headers['accept']);

  const user = getUserData(req.params.id); // shared

  if (version === 1) {
    return res.json({ user_name: user.name, email_addr: user.email });
  }
  if (version === 2) {
    return res.json({ username: user.name, email: user.email, role: user.role });
  }
});

function parseVersion(accept?: string): number {
  if (!accept) return 1;
  const match = accept.match(/application\/vnd\.example\.v(\d+)\+json/);
  return match ? parseInt(match[1], 10) : 1;
}

The takeaway

Pick URL versioning for public APIs and header versioning for internal ones. Never use query-parameter versioning for anything that lives longer than one migration cycle. Write down what counts as a breaking change and enforce it with a spec diff in CI. Share business logic across versions, translate at the boundary. Track usage, set a sunset date six months out, freeze the deprecated version, and drop it when usage hits zero, not when the calendar says so.

The API you ship today will be maintained by somebody who has never met you. Give them a versioning story that does not require reading your mind.


A note from Yojji

Building APIs that serve multiple client versions simultaneously without accruing technical debt is the kind of architectural maturity that separates a platform from a prototype. Yojji’s teams design versioning strategies from day one, bake deprecation tracking into the response layer, and keep shared business logic from duplicating across versions so that when the old one finally sunsets, the cleanup is measured in hours, not months.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product engineering from discovery through DevOps.