Most changes don't need a version bump at all
Because JSON parsing is inherently additive-tolerant — a client that doesn't recognize a new field just ignores it — adding a field, adding a new endpoint, or making a required field newly-optional are all safe to ship without any versioning ceremony. Versioning exists for the changes that actually break an existing client: removing a field, renaming one, changing a field's type (a string price becoming a nested { amount, currency } object), or changing status codes/error shapes. The three strategies below all solve the same problem — letting old and new clients hit the same API without one of them breaking — with different tradeoffs.
URL path versioning
GET /v1/users/42
GET /v2/users/42The most visible and most common approach — the version is impossible to miss in logs, browser history, or a support ticket someone pastes into Slack. The downside is that it encourages whole-API version bumps even when only one endpoint actually changed, and /v1 and /v2 often end up as near-duplicate codebases maintained in parallel far longer than planned, since there's no natural pressure to retire /v1 until every client has migrated.
Header-based versioning
curl -H "Accept: application/vnd.myapi.v2+json" https://api.example.com/users/42
# or, less formally:
curl -H "X-API-Version: 2" https://api.example.com/users/42Keeps the URL stable (useful when the URL itself is cached, bookmarked, or embedded in webhooks) and allows *per-request* version selection rather than a whole-API split. The cost is discoverability — a version living in a header is invisible in a browser address bar or in most caching layers' default cache keys, and some HTTP caches ignore headers other than a few well-known ones unless explicitly configured to vary on it (Vary: Accept).
Date-based versioning
curl -H "MyApi-Version: 2024-06-20" https://api.example.com/users/42Stripe popularized this pattern: instead of an incrementing integer, the version *is* a release date, and every account is pinned to whatever version was current when they first integrated — new accounts get the latest date by default, existing accounts keep their pinned date until they explicitly opt into a newer one. This sidesteps the "when do we release v3" question entirely (every accumulated breaking change since an account's pinned date is just... always available as the current date for new signups) at the cost of needing to maintain a *changelog of compatibility shims* per date internally, not just two parallel versions.
Comparing the three
| URL path | Header | Date-based | |
|---|---|---|---|
| Visible in browser URL | Yes | No | No |
| Cache-friendly by default | Yes | Needs Vary header | Needs Vary header |
| Version granularity | Whole API | Per-request | Per-account (usually) |
| Encourages parallel codebases | Yes, easily | Less so | No — shims, not forks |
| Notable users | GitHub REST API, Twitter API v1.1/v2 | GitHub (Accept header, alongside URL) | Stripe |
The part people skip: a deprecation policy
Whatever mechanism you pick, the harder problem is *retiring* an old version — announce a sunset date well in advance, return a Sunset or Deprecation HTTP header on every response from a deprecated version so automated monitoring can catch it, and track which clients (by API key) are still calling the old version before you actually turn it off. A versioning scheme with no deprecation path just accumulates permanent maintenance burden.