DEV Community

Jeff
Jeff

Posted on Edited on Originally published at powerduck.com

REST API versioning in 2026: paths vs headers, additive changes, and OpenAPI diffs in CI

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.

Mechanism Example Tradeoff
URI path /v1/orders Simplest to route, cache, log, and debug; visible in every tool. The default for public APIs.
Custom header X-API-Version: 2 Keeps URIs clean; harder to test in a browser, easy to forget in logs, needs explicit cache keys.
Content negotiation Accept: application/vnd.acme.v2+json "Pure REST" on paper; opaque in practice and poor ergonomics for quick calls.
Query parameter /orders?api-version=2 Easy 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"
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (5)

Collapse
 
beusebiu profile image
Eusebiu Balan •

One case where the version number wasn't enough for me: a React Native app that gets JS updates over the air. Two phones can report the same app version and only one of them has the code that understands a new habit type.

So that change rides on a capability header instead. The updated code sends X-Habit-Amounts: 1, and an app request without it gets the old shape, where an amount habit looks like a plain checkbox.

Collapse
 
jeff_pdc profile image
Jeff •

Great example, this is exactly where a path or version number stops being the axis you version on. With over-the-air JS bundles, the store version and the actual client capability are two different things, so a capability header is the honest signal: it describes what the sender understands, not what build number it claims to be. Two things that saved us: treat the header as an input to content negotiation (absent = old shape, present = new shape), and document both response shapes in the spec as a oneOf keyed off that header instead of letting the server branch on an undocumented header. Otherwise the contract is real but invisible, and a generated client can't see either branch. We model X-Habit-Amounts the same way we model Accept: it's part of the request contract, not an implementation detail.

Collapse
 
beusebiu profile image
Eusebiu Balan •

No spec here, it's a Laravel API and every client is mine. The two shapes live in feature tests instead, one setup calling as an old app with no header and one as the current app with it.

The write side needed a test too. Old app versions always send tracking_mode 'check' when someone edits a habit, so without the header an edit from an old phone would quietly turn an amount habit back into a checkbox.

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

Versioning as change management with a routing convention on top is the useful framing. Arguing paths vs headers is the easy part; agreeing on what counts as breaking, and how retirement gets announced, is what actually protects consumers.

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

The 'change-management strategy with a routing convention on top' framing is the right one. Classification is where most teams are weakest: removing an enum value or tightening a validation rule feels additive internally and still breaks consumers, which is exactly the class of change a CI diff catches better than a design review.