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.

Deprecation lifecycle An operation moves from supported to deprecated with a published sunset date, then to sunset where it returns errors, then to removal from the specification. supported no flags deprecated deprecated: true + Sunset header sunset 410 Gone removed from spec the whole notice period lives here announced in advance, measured in releases not days SDK: normal SDK: compile warning SDK: method removed gone every stage is visible in the spec, the response headers, and the generated client

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/cli v2.x or @stoplight/spectral-cli to enforce the policy as lint rules.
  • A version field in info.version that 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.

Where a deprecation must appear A single deprecated flag in the specification propagates to the rendered portal, the generated SDK, the runtime response headers and the CI policy gate. deprecated: true + x-sunset in the spec portal struck through + banner generated SDK compile-time warning runtime response Sunset + Deprecation CI policy gate fails after the date a consumer meets the notice in whichever place they look first

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:

Change bands and version impact Additive changes need a minor bump, tolerant-reader-dependent changes need a policy decision, and invalidating changes need a major bump with a deprecation period. safe — minor new operation new optional parameter relaxed constraint nothing valid becomes invalid policy decides new response field new enum value in a response new optional response header safe for tolerant readers, breaking for strict SDKs breaking — major field or operation removed new required request field type or enum narrowed needs a deprecation period first

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.