Documenting Deprecated Endpoints in OpenAPI
This guide is part of API Versioning & Deprecation in OpenAPI within OpenAPI & AsyncAPI Schema Authoring. It covers the exact markup that makes a deprecation visible everywhere it needs to be — the rendered reference, the generated SDK, and the lint gate — rather than only in a paragraph somebody wrote once. The scenario is routine and the failure is quiet: an operation is “deprecated” in a release note, nothing in the specification says so, and consumers keep integrating against it for another year.
Problem & Context
deprecated: true is a single boolean, which makes it look like the whole answer. It is not, for three reasons.
It has no date. A reader who sees “deprecated” has no way to know whether they have a week or three years, and in the absence of a date most people assume the latter and do nothing.
It has no replacement. “This is deprecated” without “use this instead” converts a reader into a support ticket, because the next question is unavoidable and the specification does not answer it.
And its placement is easy to get wrong in a way that produces no error. Setting it on the path item instead of the operation is valid YAML, valid OpenAPI, and completely invisible — the document lints clean and no renderer or generator does anything with it.
The fix is a small, consistent block of markup applied at the right level, plus a lint rule that refuses to let an incomplete one through.
Step-by-Step Solution
1. Put the flag on the operation, not the path item
The flag belongs under the HTTP method:
paths:
/invoices/search:
get: # <- the operation; the flag goes HERE
operationId: searchInvoicesLegacy
summary: Search invoices (deprecated)
deprecated: true
2. Add the retirement date and the replacement
Two extensions carry what the standard field cannot. Both are widely supported by renderers and readable by your own tooling:
x-deprecated-since: '2026-07-30'
x-sunset: '2027-01-31'
x-replaced-by: '#/paths/~1invoices/get'
3. Write the migration into the description
Put the replacement in the first sentence. Readers skim, and a migration note buried in the fourth paragraph is a migration note nobody reads:
description: |
**Deprecated on 2026-07-30, removed on 2027-01-31.**
Use `GET /invoices` with the `query` parameter instead. It accepts the
same filters, adds cursor pagination, and returns `total_cents` rather
than the floating-point `total` this operation returns.
4. Deprecate fields and parameters individually
Whole-operation deprecation is a blunt instrument. Marking the specific field lets a consumer fix one line rather than re-plan an integration:
components:
schemas:
Invoice:
type: object
properties:
total_cents:
type: integer
description: Total in minor units.
total:
type: number
deprecated: true # field-level
description: |
Deprecated — floating-point rounding made this unreliable.
Use `total_cents`. Removed in v3.
parameters:
LegacySort:
name: sort_by
in: query
deprecated: true # parameter-level
schema: { type: string }
description: Deprecated — use `order`, which accepts multiple keys.
5. Enforce the shape with a lint rule
A deprecation without a date is the state that persists forever. Make it impossible to commit:
# .spectral.yaml
extends: ['spectral:oas']
rules:
deprecated-needs-sunset:
description: A deprecated operation must publish its retirement date.
given: $.paths[*][get,put,post,delete,patch][?(@.deprecated == true)]
severity: error
then:
field: x-sunset
function: truthy
deprecated-needs-replacement:
description: A deprecated element must say what to use instead.
given: $.paths[*][get,put,post,delete,patch][?(@.deprecated == true)]
severity: error
then:
field: description
function: pattern
functionOptions:
match: '[Uu]se `?[A-Za-z0-9_/{}. -]+`? instead'
Complete Working Example
One operation carrying every part of the pattern, plus the field-level deprecation it introduces:
openapi: 3.1.0
info:
title: Acme Billing API
version: 0.0.0-dev
paths:
/invoices/search:
get:
operationId: searchInvoicesLegacy
summary: Search invoices (deprecated)
tags: [Invoices]
deprecated: true
x-deprecated-since: '2026-07-30'
x-sunset: '2027-01-31'
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 accepts the
same filters, adds cursor pagination, and returns `total_cents` rather
than the floating-point `total` this operation returns.
parameters:
- $ref: '#/components/parameters/LegacySort'
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'
Link:
description: Points at the replacement operation
schema:
type: string
example: '</v2/invoices>; rel="successor-version"'
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Invoice' }
components:
parameters:
LegacySort:
name: sort_by
in: query
deprecated: true
required: false
schema: { type: string, enum: [date, amount] }
description: Deprecated — use `order`, which accepts multiple sort keys.
schemas:
Invoice:
type: object
required: [id, total_cents, currency]
properties:
id: { type: string, example: inv_01H8XK }
currency: { type: string, example: EUR }
total_cents:
type: integer
description: Total in minor units. Use this rather than `total`.
example: 24900
total:
type: number
deprecated: true
description: |
Deprecated — floating-point rounding made this unreliable for
reconciliation. Use `total_cents`. Removed in v3.
example: 249.0
Verify the markup does what you intend before shipping it:
npx @stoplight/[email protected] lint openapi/api.yaml --ruleset .spectral.yaml
yq -r '[.paths[][]? | select(.deprecated == true) | {op: .operationId, sunset: .["x-sunset"]}]' openapi/api.yaml
✖ 0 problems (0 errors, 0 warnings, 0 infos, 0 hints)
[{"op":"searchInvoicesLegacy","sunset":"2027-01-31"}]
Gotchas & Edge Cases
Deprecating a shared component surprises everyone using it. A deprecated: true on components/parameters/LegacySort applies to every operation that $refs it, so one endpoint’s retirement flags a dozen unrelated ones. Split the component first, then deprecate the copy the retiring operation uses.
Generators vary in what they honour. Some emit a deprecation attribute for operations but ignore field-level flags; others need an option enabled. Check the generated source rather than assuming, because a deprecation that does not reach the SDK misses the consumers most likely to act on it — the ones whose compiler would have told them.
A summary that does not say “deprecated” undoes the flag. Renderers strike through the operation, but search results, sidebars, and generated method lists often show only the summary. Putting the word in the summary text costs nothing and reaches every surface.
x- extensions are yours to define, so define them once. x-sunset, x-deprecation-date, and x-removal all appear in the wild. Pick one set, document it in your style guide, and lint for it — three spellings across one specification means no tool can rely on any of them.
Deprecating everything at once trains people to ignore it. If half the operations carry a warning, the warning stops carrying information. Deprecate deliberately, in small batches, with real dates, and remove things when those dates arrive.
The specification is the announcement, not a record of one. It is tempting to treat these fields as documentation of a decision made elsewhere — the deprecation “really” happened in a planning document or a release note, and the YAML just describes it. Invert that. The specification is the artifact every consumer, renderer, generator, and mock reads, so a deprecation that is not in the specification has not happened as far as any of them are concerned. Making the markup the first step rather than the last means the portal, the SDK, and the lint gate all learn about it at the same moment, and there is no window where a release note says one thing and the contract says another.
Give the deprecation an owner and a review date. The failure mode that outlasts every other is a specification carrying six deprecations from three years ago, all with dates that have passed, none of which anyone remembers agreeing to. The sunset-must-be-future rule described in the parent guide forces the conversation on schedule, but it works far better when each deprecation also records who owns the removal. An x-deprecation-owner extension naming a team costs nothing and turns “why is this still here?” from an archaeology exercise into a question with an addressee.
FAQ
Why does the portal show no deprecation warning?
The flag is almost certainly on the path item rather than on the operation. In OpenAPI, deprecated belongs under the HTTP method, and a flag one level up is silently ignored by most renderers and generators — the document still lints clean, which is what makes this hard to spot.
Can I deprecate a single response field?
Yes. Set deprecated: true on the schema property. Renderers mark it in the schema table and generators can emit a deprecated attribute on the corresponding SDK field, which is far more actionable for consumers than deprecating the whole operation.
Does deprecating a shared component affect every operation using it?
Yes, and that is usually not what you want. Component-level deprecation applies everywhere the component is referenced, so split the component before deprecating one usage of it.
Related
- API Versioning & Deprecation in OpenAPI — the parent guide and the retirement lifecycle.
- Sunset header and deprecation policy docs — telling running clients at request time.
- Versioning OpenAPI specs with Git tags — publishing the contract that carries these flags.
- Spec Linting & Governance — where the rules above belong.