API versioning is one of those problems that looks simple until the day you need to change a field that half your customers depend on. At that point, the decision you made eighteen months ago — URI versioning, header versioning, or something spec-driven — shapes every hour of migration work ahead. This post is a practical breakdown of those three approaches, what each costs at scale, and why a growing number of platform teams are moving toward a fourth model: deriving version behavior from the OpenAPI spec itself.
The Classic Debate: URI vs. Header Versioning
URI path versioning — /v1/orders, /v2/orders — is the approach you see most frequently in production because it is operationally transparent. Every request to /v2/orders is unambiguous to a reverse proxy, an access log parser, a Kubernetes ingress rule, or a curl command in a support ticket. You can route versions at the load balancer level with no application awareness required.
The downside is that URI versioning makes the version a first-class resource identifier, which is semantically odd. The version of an order endpoint is not a property of the order; it is a property of the API contract. You end up with URLs that feel like they conflate "what resource" with "which contract version of that resource." That friction shows up in SDK design: a TypeScript client generated from your v1 spec imports types from a v1 namespace; a client generated from v2 imports from v2. If a consumer uses both (for a migration period), they carry two copies of your entire schema.
Header versioning — passing Accept: application/vnd.yourapi.v2+json or a custom API-Version: 2025-10-01 header — keeps URIs stable. Stripe uses date-based header versioning to significant effect: the same URL /v1/charges has been stable for years while the response shape has evolved substantially. The consumer opts into schema changes explicitly by updating their header value.
Header versioning's operational cost is subtler but real. You cannot differentiate versions in a CDN cache key without explicit configuration. A proxy that does not forward your custom header transparently breaks versioning silently. And documenting header versioning correctly requires more discipline from whoever writes your OpenAPI spec — each version variant needs to be represented, which many teams handle poorly (usually by maintaining parallel spec files that diverge).
Where Both Approaches Break Down at Scale
Consider a logistics-adjacent platform team maintaining 34 public API endpoints. In late 2024 they were running URI versioning (v1 through v3) with three spec files, each partially manually maintained. When they introduced a new shipment object schema in v3, the v2 spec was not updated to reflect the deprecation notice, and two enterprise integrators were still calling v2 without realizing their SDK was being served from a schema that had been silently forked from the actual v2 implementation eight months prior.
This is the core failure mode of both classic approaches: the spec and the implementation decouple. Versioning policy lives in infrastructure (routing rules, headers) but not in the spec itself. The spec becomes documentation-after-the-fact rather than a contract that the system enforces.
We are not saying URI or header versioning is fundamentally broken — both are in production at API providers handling billions of requests daily. What we are saying is that at some point the operational weight of keeping multiple specs synchronized with multiple live implementations, while also communicating deprecation timelines to consumers, exceeds what manual process can sustain without tooling built specifically around that workflow.
Spec-Driven Versioning: What It Actually Means
Spec-driven versioning treats the OpenAPI spec as the authoritative source for version lifecycle management, not just documentation. The practical shape of this approach varies, but the core principle is that version metadata — which endpoints exist in which versions, what the deprecation schedule is, which field changes are breaking — is expressed in the spec rather than inferred from routing config or communicated through release notes.
In OpenAPI 3.1 this can be partially expressed today. The deprecated: true field on an operation or schema property signals deprecation. Extensions like x-api-version or vendor-specific keys allow you to annotate lifecycle metadata. The discriminator object handles polymorphic schemas across versions. A team that has invested in Spectral linting rules against their spec can enforce that any operation marked deprecated carries a x-sunset-date extension, making deprecation timelines machine-readable:
paths:
/v2/shipments:
get:
operationId: listShipmentsV2
deprecated: true
x-sunset-date: "2026-06-01"
x-migration-guide: "https://docs.example.com/migrate/v2-to-v3"
responses:
"200":
description: List of shipments (v2 schema)
content:
application/json:
schema:
$ref: "#/components/schemas/ShipmentListV2"
When your gateway reads this spec, it can automatically inject a Sunset response header (per RFC 8594) on every v2 call — a concrete, standardized signal to consumers that they need to migrate. No manual documentation update. No Slack announcement that gets lost. The contract is in the spec.
Semver and Breaking Change Classification
One of the operational advantages of spec-driven versioning is that breaking change detection becomes automatable. Tools like oasdiff can compare two OpenAPI documents and classify diffs according to a breaking change ruleset. Removing a required field from a response is breaking. Adding a new required field to a request body is breaking. Adding a new optional field to a response is not breaking.
Wiring this into your CI pipeline — run oasdiff breaking against the diff between your spec on main and your PR branch — gives you an automated breaking change gate. A platform team shipping weekly spec updates can enforce: "no PR with breaking changes merges without a version bump, and a version bump triggers the SDK regeneration pipeline." The semver contract becomes enforced rather than aspirational.
# .github/workflows/spec-check.yml
name: OpenAPI Breaking Change Check
on: [pull_request]
jobs:
breaking-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Check for breaking changes
run: |
npx oasdiff breaking \
origin/main:openapi.yaml \
HEAD:openapi.yaml \
--fail-on ERR
Choosing the Right Model for Your Team
The honest answer is that the right versioning model is determined by your consumers, not your internal architecture preferences. If your consumers are external developers integrating via SDKs, spec-driven versioning with automated SDK regeneration on version bumps is the most defensible long-term approach — changes to the spec automatically cascade to documentation, SDKs, and gateway configuration.
If your consumers are internal services in a monorepo where you own the integration points, the overhead of formal versioning is often not warranted. A contract test suite that runs on every deploy does more practical work than a formal version strategy.
For teams in the middle — a handful of external consumers, a growing API surface — a pragmatic starting point is URI versioning for its operational transparency, combined with a single Spectral ruleset that enforces deprecation metadata on the spec. That gets you the operational clarity of URI versioning and the machine-readable lifecycle signal of spec-driven approaches, without requiring a full platform investment upfront.
The teams that get into trouble are the ones that treat versioning strategy as a one-time decision made at API launch. Version management is a continuous operational practice. The tooling you build around it — CI breaking-change gates, sunset header injection, SDK regeneration triggers — compounds over time. Starting that investment early, even at small scale, pays consistent dividends as your API surface grows.