API Versioning & Deprecation in OpenAPI
This guide is part of OpenAPI & AsyncAPI Schema Authoring and covers how a version strategy is expressed inside the specification itself: where the version lives, how deprecated and its supporting extensions describe a retirement, which HTTP headers tell a running client that it is calling something with an expiry date, and how to make a retirement policy enforceable in CI rather than aspirational in a wiki. It does not cover detecting whether a given change is breaking — that is Breaking Change Detection & Spec Diffing — but it does cover what you do once you know.
Versioning goes wrong in a specific and predictable way. A team ships v2, announces it, and then discovers a year later that half the traffic is still on v1 because nothing in the running system ever told a client it was on an old version. The documentation said so; the API did not. Everything below exists to close that gap, so that the contract, the runtime response, the generated SDK, and the portal all carry the same expiry information.
Quick reference: versioning strategies
| Strategy | Looks like | Cacheable | Visible in logs | SDK impact | Best for |
|---|---|---|---|---|---|
| URI path | /v2/invoices |
Yes, naturally | Yes | New client per major | Public APIs, third-party consumers |
| Query parameter | /invoices?version=2 |
Poorly — needs Vary |
Yes | Parameter on every call | Rarely the right answer |
| Custom header | X-API-Version: 2 |
Only with Vary |
No, unless logged | Constructor option | Internal APIs with controlled clients |
| Accept header | Accept: application/vnd.acme.v2+json |
With Vary: Accept |
No, unless logged | Content negotiation layer | Hypermedia-heavy designs |
| Date-based | Acme-Version: 2026-07-30 |
With Vary |
No, unless logged | Pinned at integration time | APIs that change continuously |
| No versioning, additive only | /invoices forever |
Yes | N/A | None | Small internal APIs with one consumer |
The bottom row deserves more respect than it usually gets. An API that only ever adds optional fields and new endpoints never needs a version, and the discipline required to stay in that regime is often cheaper than operating parallel majors. Versioning is not a feature you add because you should; it is the cost you accept once you know you will have to break something.
For everything else, path versioning wins on the criteria that matter to integrators. It survives being copy-pasted into a support ticket, it appears in access logs without extra configuration, and it makes “which version is this call?” answerable by looking at the request rather than by inspecting headers. The date-based approach is the one genuinely strong alternative — it lets you evolve continuously while pinning each consumer to the semantics that existed when they integrated — but it demands a compatibility layer for every dated behaviour, which is real engineering rather than routing.
Prerequisites & Environment Setup
- A published support policy stating how long a major version lives after its successor ships, and how much notice a deprecation carries. Without this, every deprecation becomes a negotiation.
@redocly/cliv2.x or@stoplight/spectral-clito enforce the policy as lint rules.- A version field in
info.versionthat tracks the document, distinct from the major version in the path. - Observability on version usage — you cannot retire what you cannot measure.
npm install --save-dev @redocly/cli@2 @stoplight/[email protected]
npx @redocly/cli@2 --version # 2.x
Instrument version usage before you deprecate anything. The retirement conversation is completely different when you can say “four consumers, 0.3% of traffic” instead of “we think most people have moved”:
# Per-major request share over the last 30 days, from access logs
awk '{print $7}' access.log \
| grep -oE '^/v[0-9]+' \
| sort | uniq -c | sort -rn
482913 /v2
17442 /v1
Core Configuration
Declare the version in three places that must agree: the path, the document metadata, and the servers array.
openapi: 3.1.0
info:
title: Acme Billing API
# Document version — bump on every published spec change, independent of the
# major version in the path. Consumers cite this when reporting issues.
version: 2.7.0
description: |
Major versions are supported for 12 months after their successor ships.
Deprecated operations carry a Sunset header with the exact retirement date.
servers:
- url: https://api.acme.com/v2
description: Current major
- url: https://api.acme.com/v1
description: Deprecated — sunset 2027-01-31
Then mark the retiring operation. The deprecated flag is the only standard field, and on its own it says nothing about when or what instead — so pair it with extensions that your renderer and your SDK generator can both read:
paths:
/invoices/search:
get:
operationId: searchInvoicesLegacy
summary: Search invoices (deprecated)
# Standard OpenAPI: renderers strike this through, generators emit a
# deprecation attribute in the SDK method.
deprecated: true
# Non-standard but widely supported: the machine-readable retirement plan.
x-sunset: '2027-01-31'
x-deprecated-since: '2026-07-30'
x-replaced-by: '#/paths/~1invoices/get'
description: |
Deprecated on 2026-07-30, removed on 2027-01-31.
Use `GET /invoices` with the `query` parameter instead; it supports the
same filters plus cursor pagination.
responses:
'200':
description: Matching invoices
headers:
Sunset:
description: RFC 8594 retirement date for this operation
schema: { type: string, example: 'Sat, 31 Jan 2027 23:59:59 GMT' }
Deprecation:
description: RFC 9745 deprecation timestamp
schema: { type: string, example: '@1785110400' }
Link:
description: Points at the replacement operation and its documentation
schema:
type: string
example: '</v2/invoices>; rel="successor-version"'
Deprecation is not only an operation-level concern. A field, a parameter, or an entire schema can retire on its own schedule, and marking them individually is what lets a consumer fix one call site instead of rewriting an integration:
components:
schemas:
Invoice:
type: object
properties:
id: { type: string }
total_cents:
type: integer
description: Total in minor units.
total:
type: number
deprecated: true # field-level deprecation
description: |
Deprecated — floating-point rounding made this unreliable.
Use `total_cents` instead. Removed in v3.
parameters:
LegacySort:
name: sort_by
in: query
deprecated: true # parameter-level deprecation
schema: { type: string }
description: Deprecated — use `order` which supports multiple keys.
Integration Pattern
A deprecation that only exists in the specification is a documentation change. What makes it real is the running API telling every caller, on every call, and CI refusing to let a retirement slip silently. Both halves belong in the pipeline.
The response side is three headers, emitted by the API for any operation the spec marks deprecated:
Deprecation: @1785110400
Sunset: Sat, 31 Jan 2027 23:59:59 GMT
Link: </v2/invoices>; rel="successor-version"
Link: <https://docs.acme.com/v1-migration>; rel="deprecation"; type="text/html"
Deprecation (RFC 9745) says this endpoint is deprecated as of that instant. Sunset (RFC 8594) says when it stops working. The two Link relations point at the replacement and at the migration guide. Together they are readable by a client library, a monitoring rule, or a person reading a curl -i — which is the point, because you do not know which of those will notice first.
Generate the header values from the spec rather than hardcoding them in the API, so there is exactly one source of truth:
# emit-sunset-config.sh — derive runtime deprecation config from the contract
yq -o=json '
.paths | to_entries[] as $p |
$p.value | to_entries[] |
select(.value.deprecated == true) |
{path: $p.key, method: .key, sunset: .value["x-sunset"],
successor: .value["x-replaced-by"]}
' openapi/api.yaml > deprecations.json
Then enforce the policy in CI. These three Spectral rules turn a written policy into a gate: a deprecated operation must say when it retires, must say what replaces it, and must not already be past its own date.
# .spectral.yaml
extends: ['spectral:oas']
rules:
deprecated-needs-sunset:
description: A deprecated operation must publish its retirement date.
given: $.paths[*][?(@.deprecated == true)]
severity: error
then:
field: x-sunset
function: truthy
deprecated-needs-replacement:
description: A deprecated operation must tell consumers what to use instead.
given: $.paths[*][?(@.deprecated == true)]
severity: error
then:
field: description
function: pattern
functionOptions:
match: '[Uu]se .+ instead'
sunset-must-be-future:
description: An operation past its sunset date must be removed, not left deprecated.
given: $.paths[*][?(@.deprecated == true)].x-sunset
severity: error
then:
function: schema
functionOptions:
schema:
type: string
format: date
formatMinimum: '2026-07-30'
The third rule is the one that changes behaviour. Without it, “deprecated” becomes a permanent state and the specification accumulates operations nobody has owned for years. With it, the day after a sunset date the build fails and someone has to make a decision — extend the date deliberately, or do the removal.
Advanced Options
Version the document, not just the API. info.version should change on every published specification edit even when the API is unchanged, because consumers diffing your spec need a handle for “the description improved” separately from “the contract changed”. Drive it from your release tooling rather than by hand.
Deprecate additively where you can. The cheapest deprecation is one where old and new coexist: add total_cents alongside total, mark the old field, and retire it a year later. Consumers migrate at their own pace and nothing breaks. Reserve a major version for the changes that genuinely cannot coexist.
Tell the people, not only the machines. Headers reach automated clients; a changelog entry, a release note, and an email to the accounts still calling the endpoint reach the humans who have to schedule the work. Use the traffic data to target that outreach rather than broadcasting to everyone.
Publish machine-readable deprecation metadata. A JSON endpoint listing every deprecated operation with its sunset date lets consumers automate their own migration tracking, and lets you build a dashboard of who is still calling what. It costs one generated file, derived from the same spec.
Decide in advance what “supported” means for an old major. Teams routinely announce a twelve-month support window without agreeing what they are promising during it, and then argue case by case. Write it down: does an old major receive security fixes only, or also bug fixes? Does it get new fields that were added to the current major, or does it freeze at the moment its successor shipped? Is its documentation still updated, or archived read-only? Each answer is defensible, but only a stated one lets an integrator judge whether staying on the old major for another quarter is a real option or a slow outage. The most common workable policy is: security fixes yes, bug fixes for data-loss issues only, no new features, documentation frozen with a prominent banner pointing at the current major.
Plan the removal as a release, not as a deletion. The day a sunset arrives, the operation should not simply vanish — it should return a deliberate 410 Gone with a body that names its replacement and links the migration guide, and keep doing that for a further period before the route disappears entirely. A 404 tells a stranded integrator nothing; a 410 with a structured problem document tells them exactly what happened and what to do, which converts a support ticket into a self-service fix. Removing the operation from the specification and returning 410 at runtime are two separate steps, and doing them in that order is what makes the retirement legible.
Keep sunset dates on a calendar, not a whim. Retiring on the last day of a quarter, always, means integrators can plan around a predictable rhythm. Ad-hoc dates scattered across the year produce the same total notice period with far more operational surprise.
Before choosing a version bump, place the change in one of these three bands. The boundary that causes the most trouble is the middle one — changes that are safe for tolerant readers and breaking for strictly-validating generated clients:
Verification & Testing
Assert that the specification, the runtime, and the policy agree. These three checks catch the drift that makes deprecations fail in practice.
# 1. Every deprecated operation carries a retirement date and a replacement.
npx @stoplight/[email protected] lint openapi/api.yaml --ruleset .spectral.yaml
openapi/api.yaml
no issues found
✖ 0 problems (0 errors, 0 warnings, 0 infos, 0 hints)
# 2. The running API emits the headers the spec promises.
curl -si https://api.acme.com/v1/invoices/search | grep -iE '^(deprecation|sunset|link):'
Deprecation: @1785110400
Sunset: Sat, 31 Jan 2027 23:59:59 GMT
Link: </v2/invoices>; rel="successor-version"
# 3. Nothing marked deprecated is already past its date.
yq -r '.paths[][]? | select(.deprecated == true) | .["x-sunset"]' openapi/api.yaml \
| while read -r d; do
[ "$(date -d "$d" +%s)" -gt "$(date +%s)" ] \
|| { echo "PAST SUNSET: $d — remove the operation"; exit 1; }
done && echo 'All sunset dates are in the future.'
An empty result from check 2 is the failure that matters most, because it means the specification promises a notice that consumers never actually receive. Wire all three into the same pipeline that lints the spec, as described in Spec Linting & Governance.
Troubleshooting
Renderers show no deprecation warning. deprecated: true is on the path item rather than on the operation. The flag belongs one level deeper, under the HTTP method, and a path-level flag is silently ignored by most renderers.
Generated SDKs do not mark the method deprecated. Some generators read deprecated only on operations and ignore parameter- or schema-level flags; others need a generator option enabled. Check the generated source directly rather than trusting the spec — see SDK Generation & Changelog Automation for the regeneration loop.
Traffic on the old major never falls. Almost always because nothing told the client. Confirm the Sunset header is actually emitted, then contact the top consumers directly; header-based notice reaches automated clients, but a person has to act on it.
A deprecated operation has been deprecated for three years. The policy has no enforcement. Add the sunset-must-be-future rule above, set a real date, and let the build force the conversation.
The portal shows a deprecation banner on an operation that is fine. A deprecated: true was inherited from a shared component — most often a parameter defined once under components.parameters and referenced by several operations, one of which is retiring. Component-level deprecation applies everywhere the component is referenced, so split the component before deprecating one usage of it, rather than flagging the shared definition and surprising every other operation that points at it.
Consumers report breakage after a “non-breaking” change. A field was removed or a type narrowed without a major bump. Add automated diffing so this is caught before release rather than reported after it — Detecting breaking changes with oasdiff covers the tooling.
FAQ
Should the API version go in the URL or in a header?
Put the major version in the URL path when consumers are third parties, because it is visible, cacheable, and impossible to forget. Use a header only when you control every client and need fine-grained negotiation, since a header version is invisible in logs, browser address bars, and copy-pasted examples.
What does the deprecated flag in OpenAPI actually do?
It is a boolean on an operation, parameter, or schema property that renderers display as a warning and that generators can turn into a compile-time deprecation in an SDK. It carries no dates or replacement information on its own, so pair it with an x-sunset extension and a runtime Sunset header.
How long should a deprecated endpoint stay available?
Long enough for consumers to notice and act, which for a public API means at least two release cycles or six months, whichever is longer. What matters more than the exact number is that the period is published in advance and applied consistently, so integrators can plan rather than react.
Can I remove a field without a major version bump?
No. Removing a response field, adding a required request field, or narrowing an accepted type all break existing consumers and require a major version. Adding an optional field or a new endpoint is additive and safe within a minor version.
Related
- OpenAPI & AsyncAPI Schema Authoring — the parent overview of contract authoring.
- Versioning OpenAPI specs with Git tags — tying spec versions to releases.
- Documenting deprecated endpoints in OpenAPI — the field-by-field markup.
- Sunset header and deprecation policy docs — publishing the runtime notice.
- Breaking Change Detection & Spec Diffing and Spec Linting & Governance — the gates that enforce all of this.