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.

Correct placement of the deprecated flag The flag belongs on the operation under the HTTP method, on a parameter, or on a schema property, but a flag on the path item itself is ignored. ignored — path item level paths: /invoices/search: deprecated: true ← wrong level renders normally; no warning anywhere honoured — operation level paths: /invoices/search: get: deprecated: true ← correct portal warns · SDK marks the method

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'
Deprecation granularity Deprecating a field asks for a one-line change, a parameter asks for a call-site change, and an operation asks for a rewrite, so prefer the narrowest level that expresses the intent. prefer the narrowest level that says what you mean field total → total_cents consumer changes one line cheapest to act on parameter sort_by → order consumer changes each call site findable by search operation /invoices/search → /invoices consumer rewrites the integration needs the longest notice

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.

Anatomy of a complete deprecation The standard flag drives tooling, the sunset date drives planning, the replacement pointer drives migration, and the description carries the human explanation. four parts, four different audiences deprecated: true read by renderers and generators the machines x-sunset read by the lint gate and by planners the schedule x-replaced-by read by migration tooling and links the destination description read by the person doing the work the why

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.