API versioning best practices, for the callers' sake
7 min read
API versioning is the practice of keeping an old contract working after you have changed it, so callers can move on their own schedule instead of yours. That sentence contains the two decisions that matter: what counts as changing the contract, and how long the old one keeps working. Where the version number lives, which is what most versioning debates are about, is the third question, the least important of them and the easiest to get right.
When should you version an API?
Version an API only when a change would break a correct caller. Additive changes, new fields, new endpoints, new optional parameters, do not need a version; callers written against the old contract keep working and the new capability is simply there. A breaking change does need one, because the alternative is a caller finding out from an error. Versioning every release, including the additive ones, teaches callers that versions are noise, and they stop reading the notices that matter.
The practical test is the one from the breaking-changes article: if a caller who relied only on the documented behaviour has to change something to keep working, the change needs a version. If not, ship it under the current one and write a changelog entry.
Which API versioning scheme should you use?
Use the scheme your callers can see and set most easily, which for most public APIs is a version in the URL path or a dated version header. The five common schemes differ less in capability than in what they ask of the caller, and that is the right basis for choosing.
| Scheme | Example | What the caller must do | Who uses it |
|---|---|---|---|
| URL path | /v2/invoices | Change the URL when migrating | Most public REST APIs |
| Version header | X-GitHub-Api-Version: 2022-11-28 | Send a header, or accept the default | GitHub |
| Dated account version | Stripe-Version: 2026-08-26 | Pin a date per request, or per account | Stripe |
| Query parameter | /invoices?version=2 | Append a parameter | Older APIs; rarely chosen now |
| Media type | Accept: application/vnd.example.v2+json | Negotiate content types | Purists; few callers manage it |
URL path is the most visible and the least flexible. Every caller can see which version they are on by reading a log line, and a version bump is a find-and-replace. The cost is that the whole surface moves at once: you cannot change one endpoint’s contract without minting a new version for all of them, so path versions tend to be rare and large.
Version header keeps URLs stable and lets the server pick a default for callers who send
nothing, which is how GitHub’s REST API versions
work: a date-named version in X-GitHub-Api-Version, with the oldest supported version as the
default so unversioned callers do not break. The cost is that the version is invisible in a URL and
easy to forget in a new client.
Dated account version is the header scheme with one addition: the version is stored against the
account, so every request gets it without sending anything. Stripe’s API versioning
pins each account to the version it was created on and lets a request override it with
Stripe-Version. This is the most caller-friendly scheme and the most work to run, because the
server has to translate between every supported version and the current one.
Query parameter and media type both work and both fail the visibility test in different ways: a query parameter is easy to drop when building a URL, and a media-type version is invisible to nearly every tool a caller uses to debug. Stripe’s date-based scheme is the best-known example of the date approach, and how Stripe versions its API walks through it.
How is API versioning done in practice?
In practice a version is a named set of behaviours, and the server maps each request onto one of them. The steps are the same whichever scheme carries the name.
- Name versions by date or by integer, not by semantic version. A web API is not a package.
Callers cannot pin a minor version of a URL, so
v2or2026-08-26says everything a caller needs, and semantic versioning numbers imply a compatibility promise the scheme cannot deliver. - Keep the version out of the code paths that do not care. A version should select a translation layer at the edge, not fork the business logic. Two full copies of the codebase is how a version ends up unmaintained.
- Give every version a default and a document. Callers who send no version get the oldest supported one, never the newest, so an unpinned client does not break on the day you release. Each version has a page saying what changed from the previous one.
- Set a support window and publish it. Google’s versioning guidance, AIP-185, asks for a reasonable, well-communicated transition period and recommends 180 days even for beta functionality. Pick a window, write it down, and apply it without renegotiating per version.
- Retire versions the way you retire endpoints. A version past its window gets the same
treatment as any deprecated API: an announcement, a
Sunsetheader (RFC 8594) on every response, a halfway reminder to the callers still on it, and a removal date that holds.
What is v1 and v2 in a REST API?
v1 and v2 are names for two contracts the same server supports at the same time. A v2
exists because something in v1 could not be changed without breaking its callers, so the change
went into a new contract and the old one kept working. Nothing about the numbers implies that v2
is complete or that v1 is dead; both are true only when the documentation says so. A v3
appearing every quarter is a sign that additive changes are being versioned, or that the contract
was never designed to absorb change. gRPC
solves the same problem differently: gRPC and Protobuf API changes
covers versioning through the package name in a .proto file rather than a URL path, and a wire
format where renaming a field is free but renumbering one is a breaking change no REST caller
would recognize as risky.
What should a version change announce?
A version change should announce what breaks, who it affects, how to migrate, and how long the previous version keeps working. The entry is the same shape as any other breaking-change entry, plus one line stating the support window. Here is one for a header-versioned API:
API version 2026-11-01 is available. Version 2025-06-15 is supported until 1 November 2027. New in 2026-11-01:
GET /invoicesreturnsamountin minor units as an integer instead of a decimal string, and the deprecatedcustomer_namefield is removed in favour of thecustomerobject. Affects callers on 2025-06-15 that parseamountas a string, which is the default for unpinned clients created before June 2025. Migration: parseamountas an integer and read the name fromcustomer.name. PinX-Api-Version: 2026-11-01when ready. Nothing changes for callers who do not pin.
The last sentence is the one that lets most readers stop reading, and it belongs in every version announcement. The changelog examples page includes entries from APIs that version this way, and the difference between the good ones and the rest is mostly that last line.
Who gets told when a version changes?
Everyone on the old version, individually, and the changelog for everyone else. A version change is the one case where “we posted about it” is guaranteed to miss the callers who matter: the ones who pinned a version two years ago and have not read a release note since. Usage data answers who they are; the notice has to reach them where their code is, in the response headers and in a message to the account owner.
In the loop we run, the entry announcing a version is drafted from the pull request that ships it, reviewed by a person, and published to the feed and widget, where a versioned client can read it as JSON. Anyone whose widget feedback asked for the change, or reported the bug it fixes, and became a GitHub issue the pull request closes, is told on that issue when the entry goes live. The mechanism is the same as for any entry; a version bump is just the entry with the highest stakes.
FAQ
Should every API change get a new version? No. Only breaking changes. Additive changes ship under the current version with a changelog entry. Versioning additive changes trains callers to ignore versions.
Is URL versioning or header versioning better? URL versioning is easier for callers to see and harder for you to evolve piecemeal; header versioning is the reverse. For a public API with many small clients, URL versioning fails less often. For a large API with a translation layer, dated headers scale better.
How many versions should be supported at once? As few as your support window allows, and never an unbounded number. Two or three concurrent versions is normal; more than that usually means versions are not being retired.
What should unversioned requests get? The oldest supported version, so existing unpinned clients keep working, with a response header telling them which version they received.
The technical claims in this article have not been independently reviewed. If something here is wrong, tell us and we will correct it.