API Versioning and Deprecation Policies
Versioning is the mechanism that lets an API change without breaking the clients already using it, and deprecation is the process that makes removing something a scheduled event rather than an outage. This guide extends the lifecycle overview in API Contract Fundamentals & Tool Selection and covers the whole arc: choosing where the version lives, deciding what triggers a bump, publishing the deprecation signal in a machine-readable form, serving two versions at once, and knowing when it is genuinely safe to delete the old one.
The reason most teams get this wrong is not ignorance of the options — it is that versioning is treated as a naming decision made once, when it is actually a policy that has to be enforced on every pull request. Without automated enforcement, the version number stops correlating with compatibility within about two quarters, and consumers learn to distrust it.
When to Use This Approach
A formal versioning and deprecation policy earns its overhead once you cannot redeploy every consumer yourself. Adopt it when:
- Your API is consumed by mobile clients whose old builds stay in the field for months after a release.
- External partners integrate with you on their own delivery schedule and cannot be asked to redeploy on demand.
- Multiple internal teams consume a shared platform service, and the contract-testing matrix shows consumer versions lagging behind the provider.
- You already run a breaking-change gate and need a documented answer for what happens when a change is genuinely breaking and genuinely necessary.
- Support or account management need to give a firm date when a partner asks how long an endpoint will keep working.
If you own every consumer and deploy them together, skip the ceremony: change the contract, redeploy everything, and rely on the diff gate to tell you when a coordinated release is needed.
Prerequisites
The examples use OpenAPI 3.1.1, oasdiff 1.11 for the compatibility gate, Spectral 6.11 for the policy rules, and Express 4 for the routing and header snippets. Pin them so the behaviour is reproducible:
npm install -D @stoplight/spectral-cli@6.11 @redocly/cli@1.25
go install github.com/oasdiff/oasdiff@v1.11.0
You should already have a published OpenAPI document under version control and a CI job that runs on every pull request. Familiarity with detecting breaking changes with openapi-diff in CI helps, because the diff result is what triggers the versioning policy in the first place.
Step 1: Decide What a Version Number Means
Before choosing where the version goes, decide what it promises. The useful definition is narrow: a version identifies a compatibility contract, not a release. Everything additive ships inside the current version; only a change that can break a live consumer creates a new one.
That gives you a two-value scheme for the public identifier — v1, v2 — regardless of how many releases happen inside each. Teams that map the API version to the service’s semantic version end up with v4.17.2 in a URL and consumers pinned to a patch release they had no reason to care about.
# openapi.yaml — the info block carries the release, the path carries the contract
openapi: 3.1.1
info:
title: Orders API
version: 2.14.3 # the service release — changes constantly
servers:
- url: https://api.example.com/v2 # the compatibility contract — changes rarely
The document version and the path version answer different questions: the first tells you which build you are reading, the second tells you which set of guarantees applies. The full modelling of the document skeleton is covered in the OpenAPI Specification Deep Dive.
Step 2: Choose Where the Version Lives
There are three viable placements, and the trade-offs are covered in detail in choosing between URL, header and media type versioning. The short form:
# 1. URL path — visible, cacheable, unmissable
GET /v2/orders/42
# 2. Custom header — one canonical URL, invisible in logs unless you log it
GET /orders/42
X-API-Version: 2
# 3. Media type — the most correct, the least used
GET /orders/42
Accept: application/vnd.example.order.v2+json
Pick one and apply it everywhere. The genuinely damaging outcome is a mixed estate where some endpoints are versioned in the path and others by header, because no consumer can then write one client configuration that works across the API.
Step 3: Automate the Bump Decision
The policy is worthless if a human decides case by case whether a change is breaking. Wire the classification into CI so the diff tool answers it, and the reviewer only decides what to do about the answer.
# .github/workflows/version-policy.yml
name: Version policy
on: [pull_request]
jobs:
policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Reconstruct the shipped contract
run: git show origin/main:openapi.yaml > /tmp/baseline.yaml
# Breaking changes are allowed ONLY when the server URL major version
# also changed in this pull request — that is the policy, in one step.
- name: Breaking changes require a version bump
run: |
if ! oasdiff breaking /tmp/baseline.yaml openapi.yaml --fail-on ERR; then
OLD=$(grep -oP '/v\K\d+' /tmp/baseline.yaml | head -1)
NEW=$(grep -oP '/v\K\d+' openapi.yaml | head -1)
if [ "$OLD" = "$NEW" ]; then
echo "::error::breaking change without a version bump (still v$NEW)"
exit 1
fi
echo "breaking change accompanied by v$OLD to v$NEW — allowed"
fi
The job passes in exactly two situations: the change is backward compatible, or it is breaking and the version identifier moved with it. Everything else is a red check with a message a reviewer can act on.
Step 4: Publish the Deprecation Signal
Once a successor exists, the older version has to advertise its own end. Do it in the response, not only in documentation, so a client can detect it without a human reading a changelog. The header mechanics are covered in implementing Sunset and Deprecation headers.
// middleware/deprecation.ts — Express 4
import type { RequestHandler } from "express";
export function deprecate(sunsetISO: string, guide: string): RequestHandler {
const sunset = new Date(sunsetISO).toUTCString();
return (_req, res, next) => {
res.set("Deprecation", "true"); // this resource is deprecated
res.set("Sunset", sunset); // and stops responding after this
res.set("Link", `<${guide}>; rel="deprecation"`); // where to read about it
next();
};
}
// Applied to the whole v1 router, not endpoint by endpoint.
v1Router.use(deprecate("2027-03-01T00:00:00Z", "https://docs.example.com/v2-migration"));
Mirror the same fact in the contract so generated documentation and linters can see it:
paths:
/orders/{id}:
get:
deprecated: true
description: |
Deprecated. Use GET /v2/orders/{id}. This operation stops responding
after 2027-03-01.
responses:
'200':
headers:
Sunset:
schema: { type: string }
description: RFC 1123 date after which this operation returns 410.
Step 5: Serve Both Versions From One Deployment
Two versions do not need two services. Route both into the same process and keep the difference in a translation layer, so bug fixes and security patches land in both without being applied twice. The pattern is worked through in running two API versions side by side.
// v1 is a projection of the current domain model, never a separate implementation.
v1Router.get("/orders/:id", async (req, res) => {
const order = await orders.find(req.params.id); // current domain object
res.json({
id: order.id,
status: order.status,
total: order.total.amount, // v1 sent a bare number...
currency: order.total.currency, // ...with the currency alongside it
});
});
v2Router.get("/orders/:id", async (req, res) => {
const order = await orders.find(req.params.id);
res.json({ id: order.id, status: order.status, total: order.total }); // v2: an object
});
The rule that keeps this cheap: only the serialization layer is duplicated. The moment a v1 route queries the database differently from its v2 counterpart, you have two implementations and the maintenance cost doubles for real.
Step 6: Retire by Traffic, Not by Date
The announced date tells consumers when to be ready; the traffic data tells you whether they are. Log the version and the authenticated caller on every request, and read the result before deleting anything.
-- Who is still on v1, and when did they last call it?
SELECT client_id,
COUNT(*) AS calls,
MAX(requested_at) AS last_seen
FROM api_requests
WHERE api_version = 'v1'
AND requested_at > NOW() - INTERVAL '35 days'
GROUP BY client_id
ORDER BY calls DESC;
Thirty-five days is deliberate: it spans a full monthly cycle plus a margin, so a partner’s month-end reconciliation job shows up. Quarterly jobs need a ninety-five day window. This is the single most common cause of a “nobody was using it” removal turning into an incident.
Policy Reference
The table below is the policy in one place: what each change class means for the version, the headers, and the gate.
| Change | Breaking? | Version action | Signal to consumers |
|---|---|---|---|
| Add an optional response field | no | none | changelog only |
| Add a new endpoint | no | none | changelog only |
| Add an optional request parameter | no | none | changelog only |
| Loosen a response constraint | no | none | changelog only |
| Remove a response field | yes | new major version | Deprecation + Sunset on the old one |
| Add a required request property | yes | new major version | Deprecation + Sunset on the old one |
| Rename a field | yes | new major version | serve both fields during the window |
| Change a documented status code | yes | new major version | Deprecation + Sunset on the old one |
| Narrow an enum | yes | new major version | Deprecation + Sunset on the old one |
| Mark an operation deprecated | no | none | deprecated: true + headers |
| Delete a deprecated operation | yes | removal of the old version | 410 Gone after the sunset date |
Verification
A correct deprecation is observable from the outside. Check it with a request rather than by reading the code:
$ curl -sD - https://api.example.com/v1/orders/42 -o /dev/null
HTTP/2 200
deprecation: true
sunset: Mon, 01 Mar 2027 00:00:00 GMT
link: ; rel="deprecation"
And the policy gate should be visible in CI on a pull request that removes a field without bumping the version:
$ oasdiff breaking /tmp/baseline.yaml openapi.yaml --fail-on ERR
1 breaking changes: 1 error, 0 warning
error [response-property-removed] in API GET /orders/{id}
Error: breaking change without a version bump (still v1)
After the sunset date, the retired version should answer clearly rather than 404, so a lagging client gets a diagnosable error:
$ curl -sD - https://api.example.com/v1/orders/42 -o /dev/null
HTTP/2 410
link: ; rel="deprecation"
Troubleshooting
Consumers say they never saw the deprecation notice
Almost always because the notice lived only in documentation. Headers are the durable channel: they reach an integration whose original author has left the company. Add them at the router level so no endpoint can be missed, and expose the deprecation state in whatever developer portal or console partners actually log into.
The version in the URL and the version in the spec disagree
This happens when the servers URL is edited by hand while the routing is configured elsewhere. Derive one from the other: generate the router prefix from the specification during the build, or lint the specification against the deployed routes. A mismatch means clients following the document call an endpoint that does not exist.
The diff gate fires on a change that only affects the deprecated version
Diff the versions independently. Keep one specification document per major version — openapi.v1.yaml and openapi.v2.yaml — and run the gate on each against its own baseline. A single merged document makes every classification ambiguous, because the tool cannot tell which contract a path belongs to.
Traffic to the old version drops to zero, then reappears
A batch job, a cached mobile build, or a partner’s disaster-recovery environment. This is why the retirement criterion is a full business cycle with no traffic from an identified consumer, not simply a quiet fortnight. If the caller cannot be identified, that is its own finding: unauthenticated traffic to a versioned endpoint means you cannot run this policy at all.
Two versions have drifted into two implementations
Symptomatic of version-specific bug fixes. Re-establish the rule that the older version is a projection: move any logic that exists only in the old path down into the shared domain layer, and reduce the version-specific code to field mapping. If a genuine behavioural difference has to persist, document it as a known divergence with its own removal date.
Communicating a Version Change
The mechanics above are the easy half. The half that decides whether a migration finishes on schedule is communication, and it has a shape that works.
Announce the successor before you deprecate the predecessor. Consumers need somewhere to go before they are told to leave. A deprecation notice that lands before the replacement is documented reads as a threat rather than a plan, and the first response is a support ticket asking what to do instead.
Say what changed, not that something changed. A changelog entry reading “v2 released, please migrate” moves nobody. An entry that lists the four fields whose names changed, the one that became required, and the two endpoints that merged lets a consumer estimate the work in ten minutes and schedule it.
Give one date and hold it. Moving a sunset date once teaches every consumer that the next date is also negotiable, and the migration stops being urgent for anyone. If the date genuinely has to move, move it once, explain why, and treat the new date as immovable.
Reach the code, not only the inbox. The engineer who wrote the integration has often moved on, so the durable channel is the response headers described above, plus whatever developer console the partner logs into. Email is a supplement.
Offer a migration path with a diff, not a rewrite. Where a change is mechanical — a renamed field, a moved object — publish the before-and-after payloads side by side. Most integrations are a handful of field accesses, and showing exactly which lines move converts an unbounded task into a small one.
Frequently Asked Questions
Should the API version live in the URL or in a header?
Put it in the URL when the API is public and discoverability matters — a versioned path is visible in logs, cacheable by intermediaries, and pasteable into a browser. Use a header or media type when versions are fine-grained and you want one canonical resource identifier. Most public APIs choose the URL; most internal APIs are better served by a header.
Do I need a new version for an additive change?
No. Adding an optional response field or a new endpoint is backward compatible: existing clients ignore what they do not read. Reserve a version bump for changes that can break a live consumer — removed fields, newly required request properties, tightened constraints, or changed status codes.
How long should a deprecation window be?
Long enough for every consumer to release at their own cadence, which is usually one to two quarters for internal APIs and six to twelve months for public ones with mobile clients. Publish the exact retirement date in the Sunset header from the first day of deprecation so the window is a fact, not a negotiation.
What is the difference between the Deprecation and Sunset headers?
Deprecation states that the resource is deprecated and, optionally, since when. Sunset states the date and time after which the resource will stop responding. Deprecation is the warning; Sunset is the deadline. Send both, and pair them with a Link header pointing at the migration guide.
Can I run two API versions from the same codebase?
Yes, and it is usually cheaper than running two deployments. Route both versions to the same service, share everything below the transport layer, and keep the version-specific difference in a thin translation layer that maps the older request and response shapes onto the current domain model.
How do I know when it is safe to delete a deprecated version?
By traffic, not by calendar. Log the version and caller identity on every request, and only delete once the deprecated version has served no traffic from an identified consumer for a full business cycle — including monthly and quarterly batch jobs that a two-week observation window would miss.