Teams argue for weeks about where a version number should live and spend almost no time on the two questions that actually protect consumers: which changes are genuinely breaking, and how an old version is retired. A versioning strategy is really a change-management strategy with a routing convention on top. Get the change classification and deprecation path right and the choice between paths and headers becomes a minor operational preference.

Where the version can live

Four mechanisms show up in practice, with different operational properties.

MechanismExampleTradeoff
URI path/v1/ordersSimplest to route, cache, log, and debug; visible in every tool. The default for public APIs.
Custom headerX-API-Version: 2Keeps URIs clean; harder to test in a browser, easy to forget in logs, needs explicit cache keys.
Content negotiationAccept: application/vnd.acme.v2+json"Pure REST" on paper; opaque in practice and poor ergonomics for quick calls.
Query parameter/orders?api-version=2Easy for clients; leaks into logs and bookmarks and is inconsistently cached.

For an external API consumed by browsers, mobile apps, agents, and third-party backends, path versioning is usually the right default: it is unambiguous at the edge, survives redirects and shared caches without special Vary rules, and shows up in every access log and APM trace. Header or media-type versioning is a reasonable fit for tightly controlled internal services where a gateway enforces it consistently. Pick one and document it; supporting two versioning schemes at once is a permanent tax.

Additive is free; structural is not

Most versioning pain comes from misclassifying changes. Wire compatibility is the test.

Generally non-breaking, safe to ship without a new major version:

  • Adding a new endpoint.
  • Adding an optional request parameter or an optional request field.
  • Adding a new response field clients are told to ignore.
  • Adding a new optional request header.
  • Relaxing a validation rule, such as raising a maximum length.
  • Adding a new response header.

Generally breaking, requiring a new version or a coordinated migration:

  • Removing or renaming an endpoint, field, enum value, or header.
  • Changing a field's type, format, or unit.
  • Making an optional request field or parameter required.
  • Tightening validation, such as adding a minimum or reducing an allowed length.
  • Changing the meaning of a status code or the semantics of an existing value.
  • Changing pagination defaults, error envelope shape, or authentication requirements.

One case deserves care: adding an enum value is wire-compatible but breaks clients with exhaustive switch statements or closed schemas that reject unknown values. Treat it as a consumer-facing change worth announcing even though it is not a protocol break. The same discipline matters for agents, which often map finite value sets to tool arguments; a new status can silently confuse them.

Prefer additive versions over silent edits

When you can, evolve without a new version by making the change additive and keeping old behavior intact: introduce a new field alongside the old one, accept both on input, and populate both on output during a migration window. Reserve a new path version for changes that cannot be hidden behind compatibility, such as a corrected error envelope or a restructured resource model. This keeps the number of supported versions small, which is the real goal.

Mark and schedule retirements with standard headers

A deprecated endpoint should tell every caller, including ones that never read your changelog. Two standard headers do this. Deprecation (RFC 9745) signals that the endpoint is deprecated, optionally with a timestamp, and Sunset (RFC 8594) gives the date it will stop working. A successor link points clients at the replacement:

HTTP/1.1 200 OK
Deprecation: @Mon, 02 Mar 2026 00:00:00 GMT
Sunset: Mon, 31 Aug 2026 23:59:59 GMT
Link: <https://api.example.com/v2/orders>; rel="successor-version"

In the OpenAPI document, mark the operation and any deprecated parameters or properties so generated clients and docs reflect the phase-out:

paths:
  /v1/orders:
    get:
      deprecated: true
      description: Use /v2/orders. Sunset 2026-08-31.
      responses:
        "200": { description: Orders in the legacy envelope }
  /v2/orders:
    get:
      responses:
        "200": { description: Orders in the current envelope }

Publish a short migration guide per breaking version, keep an overlapping window where both versions run (many teams standardize on supporting the current and previous major), and instrument which callers still hit the old version so the sunset date is driven by data rather than a guess.

Enforce the classification with an OpenAPI diff in CI

Humans reliably under-report breaking changes, so make the diff mechanical. Keep the OpenAPI document in git alongside the code, and on every pull request compare the proposed spec against the base branch, classifying each difference as additive or breaking:

  • A new operation or optional field passes automatically.
  • A removed operation, removed field, changed type, or newly required parameter fails the build or requires an explicit version-bump label.
  • A change flagged deprecated is reported but allowed.

The same diff should catch accidental contract drift between what the code does and what the spec says, which is where scanning code into OpenAPI and re-scanning on change pays off: the spec is regenerated from evidence, then diffed against the previously reviewed document, with human edits preserved. Route additions, changes, and removals are reported keyed by method and path, and removed routes are flagged for review rather than deleted, so a deprecation cannot slip in unannounced.

Organizing multiple versions in the spec

Two layouts are common. Keep each major version in its own document or tag set (v1, v2) when the models diverge heavily, which makes code generation per version clean. Keep one document with /v1 and /v2 paths when versions share most schemas and you want a single diff surface. Either way, mark the lifecycle in metadata, keep old versions out of new-developer onboarding, and delete a version's contract from the active spec only after the sunset date has passed and the traffic is gone.

Versioning is ultimately a promise about change: consumers should be able to adopt additive changes without action, receive loud and dated warnings before anything breaks, and verify the promise in CI. A local, git-diffable spec makes that loop cheap; the local-first API workflow covers the file-based setup, and scenario testing from OpenAPI shows how to pin migration behavior with tests. You can explore the spec-driven, diff-aware workflow in the online demo.