API versioning: URL, header, or date-pinning (and when not to version at all)

An API version number is a promise about what won’t change. Everything else about the versioning debate — path segment or header, integer or date — is mechanism. The decision that matters is upstream of all of it: when you have to break something, who absorbs the cost — you, or every program anyone ever built against you?

The reason this deserves more care than most architecture choices is the reason API changelogs are unforgiving: your consumers are programs. A human squints at a slightly different screen and adapts; a script does exactly what it did yesterday and fails. And per Hyrum’s Law, with enough consumers every observable behavior of your API becomes something somebody depends on — whether you documented it or not. Versioning is the machinery you build so that change and reliability can coexist. This guide covers the schemes actually in use, the one most teams should try first (not versioning), what old versions really cost, and — because this is a changelog site — how to announce every step of it.

Option zero: don’t version — evolve

The cheapest version bump is the one you never ship. Most changes an API needs are additive: a new endpoint, a new optional parameter, a new field in a response. Additive changes don’t need a version — old clients ignore what they don’t know — with the caveat that some “additive” changes secretly aren’t (new enum values crash exhaustive switches, tightened validation rejects requests that used to work, changed defaults alter behavior for everyone who didn’t pass the parameter — the API changelog guide has the full taxonomy).

For changes that would break, there’s a discipline that often makes the version unnecessary: expand and contract. Add the new field or endpoint alongside the old one; serve both while consumers migrate; deprecate the old name with a date; remove it after the window closes. The rename never breaks anyone, because at every moment there was a working path. This is how GraphQL’s official guidance avoids versioning entirely — continuous evolution plus field-level deprecation — and it works just as well for REST. The cost is honesty about the tail: expand-contract without the contract step is how APIs accumulate three names for everything.

When is a real version unavoidable? When the meaning of an existing name has to change — the same field must return different semantics, the same endpoint must behave differently — and no parallel name can express it. That’s rarer than teams think. If you’re reaching for /v2 to ship a batch of features, you don’t need a version; you need a launch announcement.

URL versioning: visible and coarse

/v1/users is the scheme everyone recognizes, and its virtues are all forms of visibility: the version is in every copied curl command, every log line, every bug report, every Stack Overflow answer. Nobody ever wonders which version a request hit. It requires zero client tooling and zero documentation beyond the URL itself.

Its cost is coarseness. The v1 applies to the whole surface, so moving to /v2/ asks every consumer to re-verify everything — even the 95% of endpoints that didn’t change. That re-verification bill is why URL-versioned APIs tend toward big, rare, dreaded version bumps, and why so many of them are still on /v1 a decade in (which is the scheme quietly working as designed: the number’s job is to never change). Two norms keep URL versioning honest: only the major goes in the path — /v1.2/ contradicts the whole point of minor versions, which is that you shouldn’t have to care — and a new path version must actually contain breaking changes (see anti-patterns).

Header versioning: clean URLs, invisible consequences

The REST-purist objection to /v2/users is that the resource didn’t change identity, your representation of it did — so the version belongs in content negotiation: Accept: application/vnd.acme.v2+json, or more plainly a custom X-Api-Version: 2 header. URLs stay permanent, versions stay per-request.

In practice the elegance buys you one large operational problem: invisibility. A curl reproduction without the header silently hits the default version, which may not be the version the bug lives in. Logs, link previews, cache keys, and copy-pasted examples all lose the version information that a URL carries for free. Header versioning is workable — plenty of serious APIs use it — but only with two disciplines: log the resolved version on every request, and never let the default float. If the default is “latest,” every release is a stealth breaking change for every client that didn’t pin — which is most of them, because the scheme made pinning optional. Default to the oldest supported version, or per-consumer pinning, or make the header mandatory.

Date pinning: the Stripe model

The most evolved answer treats versions as dates pinned per account. Stripe’s scheme is the reference implementation: your account is pinned to the API as it looked when you signed up (2026-06-17-style versions); any individual request can override with a header to test a newer one; when you’re ready, you upgrade the pin yourself, and internally the platform runs each request through a chain of small transforms between adjacent versions.

What this buys is remarkable: the provider can make breaking-ish improvements continuously without ever scheduling a migration event, because nobody receives a change they didn’t opt into; each consumer migrates alone, when ready, testing with a header first, with a working rollback until they commit. It’s calendar versioning applied per-consumer, and for an API-as-product business it’s close to the ideal experience.

What it costs is the part that doesn’t make the conference talk: the transform chain is a real product that someone maintains forever, the live-version count grows monotonically, and every new feature has to be threaded through every supported vintage. Pick this scheme when the API is the product and integration stability is what customers are paying for. If you’re versioning an internal service or a young public API, start with evolution + a URL major; you can adopt date pinning later — Stripe did.

Version the surface, not the endpoint

Whatever scheme you pick, apply it to the API as a whole. Per-endpoint versioning — /v1/users next to /v3/orders — turns every integration into a compatibility matrix nobody can hold in their head, makes generated SDKs incoherent, and produces changelog entries that read like dependency lockfiles. If one endpoint genuinely must break, that’s either an expand-contract job (new endpoint name, old one deprecated) or the seed of the next surface-wide version. Version soup is the worst of both: all the migration cost of versioning with none of the predictability.

Old versions are where the real cost lives

Shipping /v2 is the cheap part. The expensive part starts the next morning: every version that has live traffic needs docs, tests, security fixes, and support — indefinitely, until you turn it off. And here’s the uncomfortable empirical rule: consumers do not migrate without a forcing function. A working integration is a thing nobody touches on purpose. Hoping v1 traffic will fade on its own is not a plan; this is the discipline that makes versioning sustainable:

  • Publish a deprecation policy before you need it — how long old versions live, how removal is announced, where to subscribe. Calm-weather policy beats mid-incident improvisation.
  • Announce deprecation with dates, not vibes — the full breaking-changes playbook: announce, remind, remove, never move a date earlier.
  • Signal in-bandRFC 8594 Deprecation/Sunset headers on old-version responses, warnings in SDK logs. The developer who integrated left the company; the program is the only thing still listening.
  • Measure per-version traffic, and contact the holdouts personally before removal — at the end, the long tail is short enough to email.
  • Sunset loudly — a removal is a shutdown announcement, not a changelog footnote.

One thing that should not multiply with versions: your changelog. Run one stream, with entries stating which versions they touch (“v2 only,” “all supported versions”). Readers on v1 need to see what v2 fixed — that’s your migration marketing — and a changelog-per-version guarantees one of them goes stale. The same one-stream rule that works for LTS lines works here.

Announcing a new version

The v2 launch entry is the most-read thing you’ll publish that year, and its job is honest persuasion: every reader is deciding whether migrating is worth their sprint. Lead with what v2 buys them; put Breaking: right after it, per the API entry template; state the migration effort honestly (an accurate “about a day” builds more trust than an optimistic “minutes”); link the migration guide — which lives in your docs, not pasted into the entry — and state v1’s support window in the launch entry. “v1 is supported until at least June 2027” on day one reads as respect; “we’ll see” reads as a countdown you haven’t admitted to. A skeleton:

## API v2 is here — cursor pagination, idempotency keys, 3x faster search

v2 is available today at /v2/ for all accounts. v1 keeps working and is
supported until at least 2027-06-30 (see our deprecation policy: <url>).

Why migrate: cursor-based pagination (no more skipped rows), idempotency
keys on all writes, and search that returns in ~80ms.

Breaking (full migration guide: <url>):
- Pagination: page/per_page params are replaced by cursor/limit.
- Timestamps: all responses now use ISO 8601 UTC (was unix seconds).
- Errors: error bodies use RFC 9457 problem+json.

Migration effort: most integrations take under a day; the guide has a
checklist. SDK users: upgrade to acme-js 4.x, which targets v2.

Unchanged: authentication, rate limits, webhooks, and every /v1/ endpoint
— nothing changes for existing integrations until you opt in.

Anti-patterns

  • The big-bang v2 that never ships. The rewrite grows until it’s too big to finish, while v1 is frozen because “v2 will fix it.” Version bumps should be as small as you can make them — a handful of breaks you couldn’t expand-contract away — not a decade of regrets settled at once.
  • Version soup. Three endpoints on three versions and an SDK that lies about all of them. Version the surface.
  • Breaking inside a version. If /v1 behavior changes incompatibly, you’ve spent the entire point of having the number. That includes “quiet fixes” that change what responses look like — per Hyrum, someone parsed it.
  • Default-to-latest. Unpinned clients receiving each new version automatically means your releases are other people’s outages. Defaults must be stable; upgrades must be chosen.
  • The forever promise. “We’ll never remove v1” is a promise you’ll break exactly once, at maximum cost. Promise a generous window, in a published policy, instead.
  • Version-as-marketing. Bumping /v2 to /v3 for a feature drop with zero breaking changes spends everyone’s re-verification budget to look shiny. Features ship in the version people already use; the number is infrastructure, not an announcement channel.

Where Wakelog fits

Honesty first: Wakelog doesn’t gateway, route, or transform your API — the versioning machinery is yours. What we host is the communication layer that makes any scheme survivable: a changelog with stable permalinks (the URL your RFC 8594 Sunset header, SDK warning, and holdout email all point at), a breaking tag so removals are structurally loud, tag filters that give you a live deprecations table for free, and RSS/JSON feeds plus a list API your consumers’ CI can watch — so the programs that depend on you find out in-band, not from a failing build. Posting fits the release pipeline: one curl from the same job that deploys the new version. Create a project or preview your existing changelog in 30 seconds.

Related guides

Last updated 2026-08-01 · All guides