APIs & developer platforms / Comparison

API Versioning Without Regret: URLs, Headers, and Sunset Policies Compared

Compare URL, header, and media-type versioning on real tradeoffs, and design a sunset policy that retires old versions without breaking clients.

You will decide how your API versions, why most teams regret the choice they made by default, and how to retire an old version without turning a routine cleanup into a support incident. This is a comparison, but the real subject is not syntax. It is the contract you make with every client who integrates once and then does not touch their code for a long time.

Versioning is where API design meets time. Your first version will be wrong in ways you cannot see yet. Fields will be named badly. A resource will turn out to be two resources. An error shape that seemed fine will block a client from handling failures properly. The question is never whether you will need to change the contract. It is whether that change breaks everyone at once, a few clients at a time, or nobody until they choose to move.

First, decide what counts as a breaking change

Teams argue about versioning schemes while being vague about the thing being versioned. Get precise first. The following are breaking for any client that depends on them, and no versioning scheme makes them safe without a new version or an equivalent migration path:

  • Removing or renaming a field, endpoint, or enum value.
  • Changing a field’s type, format, or meaning, even if the name stays the same.
  • Making an optional request field required.
  • Tightening validation so previously accepted input now fails.
  • Changing an error code or structure that clients branch on.
  • Changing pagination, sorting, or filtering defaults in a way that alters results.

The following are usually safe to ship without a new version, provided you have told clients to tolerate them:

  • Adding a new optional field to a response.
  • Adding a new endpoint.
  • Adding a new optional request parameter with a safe default.
  • Adding a new enum value, only if you have warned clients to handle unknown values gracefully.

That last point deserves emphasis. If your clients crash on an unknown enum value, then adding one is breaking in practice, whatever the theory says. Your versioning policy has to be written against how clients actually behave, not how you wish they behaved. Document tolerant-reader expectations early, in your first version, before you need them.

The three schemes, honestly assessed

URL versioning

The version lives in the path: /v1/orders, /v2/orders.

This is the most visible scheme and the easiest to operate. You can route by path at a gateway, cache by path without special handling, grep your logs by version trivially, and send a client a link that reproduces their exact request. A support engineer can test the same call the client made by copying the URL. That operational clarity is why so many public APIs use it.

The costs are real. A URL is supposed to identify a resource, and purists will point out, correctly, that the same order is the same resource in both versions. In practice the larger cost is duplication. Every resource exists once per version, client SDKs hard-code the prefix everywhere, and a client migrating must change every call site rather than one configuration value. URL versioning also tempts teams to version the entire API when only one resource changed, because the prefix is global.

Header versioning

The version lives in a dedicated header: Api-Version: 2 or a vendor header defined by the API.

URLs stay clean and stable, which keeps resource identity pure and avoids duplicating every path. A client sets the version once in its HTTP client configuration and forgets it. That centralisation makes migration a smaller change in client code, a single value rather than a sweep through every request.

The costs show up in visibility and tooling. The version is invisible in a copied URL, which makes support reproductions and shared examples error-prone: two identical-looking requests behave differently because of a header nobody pasted. Browsers cannot set arbitrary headers on a plain navigation, so interactive exploration is harder. Caches must be configured to vary on the header, and if you forget, a cache can serve one version’s response to a client that asked for another. That failure is silent and confusing.

Media type versioning

The version lives in the Accept header: Accept: application/vnd.example.v2+json.

This is the most semantically precise scheme. It versions the representation, which is what actually changed, and it allows in theory a different version per resource in the same system. Content negotiation is the mechanism HTTP provides for exactly this problem.

In practice it is the scheme teams most often regret choosing as their primary mechanism. It is the least discoverable for new developers, the hardest to test in a browser or a simple command-line call, and the easiest to get wrong in client code, where a missing or malformed Accept header quietly returns a default version the client did not intend. Tooling, gateways, and documentation generators handle it less uniformly than paths or simple headers. The precision is real, but you pay for it on every support ticket.

Side by side

CriterionURLCustom headerMedia type
Discoverability for new developersHigh, visible in every exampleMedium, must be documentedLow, easy to miss entirely
Ease of reproducing a client issueHigh, copy the URLMedium, header must be sharedLow, negotiation is invisible
Caching behaviourSimple, version is in the cache key by defaultNeeds explicit vary configurationNeeds explicit vary configuration
GranularityWhole API by defaultWhole API by defaultPer-resource in theory
Client migration effortEvery call site changesOne configuration value changesOne configuration value changes
Gateway and routing simplicityHighMediumMedium to low
Risk of silently serving the wrong versionLowMedium, if caching is misconfiguredHigher, if the header is omitted

No scheme wins on every row, which is why the decision should be driven by who your clients are. A public API consumed by thousands of developers you will never meet should optimise for discoverability and reproducibility, even at the cost of theoretical purity. An internal platform API consumed by a handful of first-party clients you deploy alongside can reasonably optimise for clean URLs and centralised configuration.

What most teams actually get wrong

The scheme is rarely the failure. The failures repeat across schemes.

Versioning too often. If every contract tweak becomes a new major version, you will be maintaining a long tail of versions nobody wants to support and clients learn that upgrading is constant unpaid work. Batch breaking changes. Hold them, communicate them together, and release a new version when the accumulated change justifies a migration.

Versioning too granularly. Per-endpoint versioning sounds flexible and produces a matrix nobody can reason about. A client ends up on version three of one resource and version one of another, your documentation must describe every combination, and your test coverage thins across all of them. Version at the API level unless you have an unusually strong reason not to.

Having no default policy. Decide now what a request with no version gets. Rejecting it is strict and breaks casual exploration. Defaulting to the oldest supported version is safe but can hide that a client never configured anything. Defaulting to the latest is dangerous, because a client that set nothing silently changes contract the day you release. The most defensible default for a public API is the version that was current when the client’s credentials were created, if your platform can support that, or the oldest supported version with a clear response header naming the version actually served.

Forgetting the served-version signal. Whatever scheme you choose, return the version you served in a response header on every call. It costs nothing, it makes support conversations factual instead of speculative, and it gives you telemetry on who is actually still calling the old version, which you will need for the next section.

The sunset policy is the versioning strategy

A versioning scheme without a retirement plan is a promise to maintain every version forever. Write the sunset policy when you release version two, not when you are desperate to delete version one.

A workable policy has five elements.

  1. A published support window. State plainly how long a version is supported after its successor launches. Clients plan migrations around their own release cycles, so a window measured in months, not weeks, is what makes migration possible rather than theoretical. Whatever window you choose, publish it in the same place clients read your API reference, not in a changelog they may never see.

  2. Signals in the response. Use the standard deprecation and sunset response headers so tooling and observability can detect them automatically. A deprecation signal should appear from the moment you announce the retirement, and the sunset signal should carry the date after which the version may stop responding. Pair them with a link header pointing to your migration guide, so a developer who sees the warning in their logs lands directly on the instructions.

Here is the shape of those headers on a deprecated version’s response:

response-headers.http
HTTP/1.1 200 OK
Content-Type: application/json
Api-Version: 1
Deprecation: true
Sunset: <the published retirement date for this version>
Link: <https://example.com/docs/migrate-v1-to-v2>; rel="sunset"

Do not copy a calendar date from any example, including your own drafts, without checking it against the policy you actually published to clients. The header is a commitment. If it disagrees with your documentation, clients will trust whichever one they saw, and you will own the confusion.

  1. A migration guide that does the work. List every breaking change, show before and after payloads for each one, and call out the changes that alter behaviour without altering shape, such as new default sorting. Those silent changes cause the worst migration bugs because nothing looks broken.

  2. Telemetry-driven outreach. Use your served-version signal to count active clients per version and identify the integrations still calling the old one. Contact them directly long before the deadline. A generic announcement reaches nobody. A message that says “your integration made calls on the retiring version this week” reaches the right person.

  3. A staged shutdown. Before the final date, schedule short brownouts where the old version returns errors for brief, announced windows. This is the most effective technique in the entire policy. Clients who ignored every email discover their dependency during a ten-minute brownout, not during your permanent shutdown. Announce the brownouts in the same message as the deadline, keep them short, and exempt nothing silently, or you will teach clients that warnings do not apply to them.

src/middleware/versionGate.ts
type VersionPolicy = {
  version: number;
  deprecated: boolean;
  // Brownout windows are configured operationally, not hard-coded.
  inBrownout: (now: Date) => boolean;
};
 
export function versionGate(policy: VersionPolicy) {
  return (req: any, res: any, next: any) => {
    res.setHeader("Api-Version", String(policy.version));
 
    if (policy.deprecated) {
      res.setHeader("Deprecation", "true");
      res.setHeader(
        "Link",
        `<https://example.com/docs/migrate>; rel="sunset"`
      );
    }
 
    if (policy.inBrownout(new Date())) {
      return res.status(410).json({
        error: "version_retiring",
        message:
          "This API version is in a scheduled brownout ahead of retirement. See the migration guide.",
      });
    }
 
    return next();
  };
}

Return a distinct status for a retired version rather than a generic not-found. A client hitting a retired version should receive a response that names the problem and points at the migration guide, not a 404 that sends them debugging their URL.

Evolving without versioning, when you can

The cheapest version is the one you never release. Before committing to a new major version, check whether an additive design avoids it. Can the new behaviour be an opt-in request parameter or a new field rather than a changed default? Can the new resource live alongside the old one while clients migrate at their own pace? Can you expose the new error shape under a new field while keeping the old one populated during a transition?

Additive evolution has limits. It accumulates optional parameters and parallel fields until the contract is a museum. Use it to avoid versions for small changes, and accept a new major version when the contract genuinely needs a clean break. The judgement is the skill.

Verdict

For a public API with external developers, choose URL versioning. Its theoretical impurity is a small price for discoverability, reproducible support, and simple caching and routing, and those are the properties you will rely on every week for years. For a platform API with a small number of first-party clients under your control, a custom header is a clean and defensible choice. Reserve media type versioning for the rare case where per-representation negotiation is a genuine requirement rather than an aesthetic preference.

Whichever you choose, decide your breaking-change definition before you need it, return the served version on every response, and write the sunset policy, complete with headers, outreach, and brownouts, on the day you ship your second version. Teams regret versioning when the scheme is exotic or the retirement is improvised. They rarely regret a boring scheme with a serious sunset plan.

Written by Joan Ayebola

I’m a software engineer and technical writer with more than four years of experience building software and explaining how it works. I write tested tutorials, migration guides, and documentation that help developers understand a tool quickly and use it with confidence.

Keep reading