API Versioning Strategies: URL, Header, or No Version at All

Photo by Beyzaa Yurtkuran

Photo by Beyzaa Yurtkuran
The moment a second team, a mobile app, or a paying customer starts calling your REST API, you lose the right to change it casually. Every field you rename is somebody's broken production deploy. API versioning is how you keep evolving anyway, and the industry has settled into a handful of strategies: version in the URL, version in a header, date-pinned versions per account, or the quietly radical option of never versioning and only making additive changes.
I have shipped all of these across ERP integrations and public-facing backends, and the differences are not academic; they decide how much pain your clients and your own codebase absorb over years. The answer up front for most small teams: design so you rarely need to break anything, use additive changes aggressively, and keep one coarse URL version as an escape hatch. Here is the full decision, with Stripe and GitHub as the reference implementations worth copying.
Versioning conversations go in circles until everyone shares a definition of a breaking change. The practical list is short. These break integrations:
Almost everything else is additive and safe: new endpoints, new optional fields in responses, new optional request parameters, new enum values where clients were told to handle unknowns. The single most valuable sentence in your API docs is the one telling consumers that new fields may appear at any time and must be ignored if unrecognized. That one rule converts a huge class of would-be breaking changes into routine releases.
Each strategy answers one question: where does the client declare which contract it expects? The table summarizes how that one choice plays out, and the snippet below shows the same request under each scheme:
| Strategy | Looks like | Strengths | Costs |
|---|---|---|---|
| URL path | Path prefix v1, v2 in every route | Impossible to miss, trivially cacheable and routable, easy to run v1 and v2 side by side as separate deployments. | Coarse-grained: bumping for one endpoint drags the whole API along. Encourages big-bang migrations that clients postpone for years. |
| Custom header | A version header on each request, GitHub style | URLs stay stable and resource-oriented; version moves with the request, so granularity is flexible; defaults keep old clients working. | Invisible in casual testing and easy to forget in one of your five HTTP clients; intermediaries and caches must be taught to vary on it. |
| Date-pinned per account | Account pinned to a release date, Stripe style | Clients never break without acting: each account stays on the behavior of its signup-era version until it opts into upgrading. | By far the most expensive to operate: every breaking change becomes a transformation module you maintain potentially for many years. |
| No versioning (additive only) | One living contract, expand and contract | Zero version plumbing; forces deliberately compatible design; ideal for internal APIs where you control every consumer. | Demands real discipline and tooling like deprecation telemetry; a genuinely unavoidable breaking change still needs an escape plan. |
# The same request under each strategy:
# 1. URL path version
GET /v2/invoices/inv_123
# 2. Header version (GitHub style)
GET /invoices/inv_123
X-GitHub-Api-Version: 2022-11-28
# 3. Date-pinned account version (Stripe style)
GET /v1/invoices/inv_123
Stripe-Version: 2024-06-20 # override the account's pinned date
# 4. Media-type version (rare in practice)
GET /invoices/inv_123
Accept: application/vnd.myapp.v2+jsonStripe's model, described in their engineering blog, is the gold standard for public APIs with long-tail integrations. Versions are rolling and named by release date. Each account is pinned to the version current at its first request, and stays there until it explicitly upgrades. Internally, every backwards-incompatible change is encapsulated in a version change module that knows how to transform a current response into the older shape.
Requests from an account pinned three years back pass through the stack once at the current version, then walk back through each transformation module until the response matches what that account expects. The genius is in the constraint: version logic lives in declarative, self-documenting modules instead of conditionals scattered through business code. The price is real engineering investment, which is exactly why you should admire this model and probably not adopt it before you have Stripe-scale backwards-compatibility obligations.
GitHub's REST API takes the lighter-weight cousin of the same idea. Clients send a version header with a date value, like the 2022-11-28 version named in their docs, and requests without the header default to that baseline version so existing integrations keep working. Breaking changes ship only in new dated versions, additive changes flow into all supported versions, and GitHub commits to at least 24 months of support for each version with deprecation notice before sunset. For API producers the lesson is the policy, not the header: name your contract, default it sensibly, and publish a support window so clients can plan instead of fear.
The failure mode of every versioning scheme is the same: versions are easy to create and politically painful to retire. Each live version multiplies your test matrix and doubles the surface where security fixes must land. Before introducing v2, write down the v1 sunset policy, the dates, the comms plan, and who owns chasing the last ten clients. If nobody will own that, you are not versioning, you are accreting.
For internal APIs, and for most B2B systems where you know every consumer, the strongest strategy is designing so versions are rarely needed. The toolkit is the expand-and-contract migration, applied to contracts instead of database schemas:
Whatever strategy you choose, add a deprecation observability layer first: log a structured warning whenever a deprecated field or endpoint is hit, tagged by API key. When we did this for an ERP integration API, the dashboard showed exactly three consumers on the legacy invoice shape. Two were our own internal tools. Deleting the old contract went from a scary unknown to a 30-minute task with two Slack messages.
If you run a backend consumed by a handful of known clients, here is the playbook that has aged well across my projects:
Strip away the mechanics and API versioning is just promise management: deciding which behaviors you guarantee, to whom, and for how long. URL versions make the promise loud and coarse, headers make it precise and quiet, Stripe's date pinning makes it eternal and expensive, and additive-only evolution avoids re-promising altogether. The takeaway: spend your discipline on not breaking contracts, instrument deprecated usage so retirement is data-driven, and keep one cheap version lever for the day you truly need it. The best version of your API is the one clients never had to think about.