The right way to version a REST API is to avoid versioning until a change would break existing clients, then pick one mechanism and apply it consistently. URI versioning (/v2/customers) is the simplest choice and works well with routing and caching. Header, media type and date-based schemes keep resource URLs stable or let clients upgrade in small steps, but they cost more to implement and to cache. Whatever you choose, pair it with a published deprecation process.
Who this is for
This guide is for developers and architects who own an HTTP API that other teams or customers call, and who need to decide how to ship a breaking change without breaking them. By the end you will have a decision framework, concrete request and response examples for each strategy, and a deprecation workflow using standard HTTP headers.
What counts as a breaking change
Versioning exists to protect clients from breaking changes, so start by being precise about what breaks them. Microsoft's web API design guidance notes that adding content to an existing resource might not be a breaking change, because client applications that aren't expecting the new content ignore it. For example, adding a dateCreated field to a customer resource is usually safe for clients that ignore unrecognised fields.
More drastic modifications to the schema, including field removals or renaming, can break clients. So can changes to the relationships between resources. Use this working list:
| Change | Usually breaking? |
|---|---|
| Add an optional response field | No, if clients ignore unknown fields |
| Add an optional request parameter | No |
| Add a new endpoint or resource | No |
| Remove or rename a field | Yes |
| Change a field type or meaning | Yes |
Restructure a field, for example split address into sub-fields | Yes |
| Make an optional request parameter required | Yes |
| Change an error code that clients branch on | Yes |
Write this policy down and publish it. A rule such as "we may add fields at any time; clients must ignore fields they don't understand" is what makes additive changes safe, and it is the single cheapest way to reduce the number of versions you ever create.
The main versioning strategies
Microsoft's Azure Architecture Center describes four mechanisms for exposing multiple versions: URI, query string, header and media type versioning. A fifth approach, date-based versioning, is documented by Stripe.
URI versioning
You add a version number to the path. When the address field is restructured into streetAddress, city, state and zipCode, the new shape is exposed at a new URI while the old URI continues to return the original shape:
GET https://api.contoso.com/customers/3
GET https://api.contoso.com/v2/customers/3Microsoft describes this as simple, but dependent on the server routing each request to the right endpoint. It can become unwieldy as the API goes through many iterations and the server has to support many versions. It also complicates HATEOAS, because every link has to include the version number. A purist objection is that the client is fetching the same customer either way, so the URI arguably should not differ.
Query string versioning
The version is a query parameter, such as https://api.contoso.com/customers/3?version=2. The parameter should default to a meaningful value, such as 1, if older clients omit it. The same resource always lives at the same path, which some teams prefer semantically. The server must parse the query string and branch on it, and the approach complicates HATEOAS in the same way as URI versioning. Microsoft also notes that some older browsers and proxies don't cache responses for requests that include a query string.
Header versioning
The client sends a custom header that carries the version:
GET https://api.contoso.com/customers/3
Custom-Header: api-version=2The URI is stable, but the server needs extra logic to read the header, and links in responses must include the right header. Microsoft cautions that header and media type schemes typically require more logic to examine the header values, and that many clients using different versions can produce a lot of duplicated data in a server-side cache.
Media type versioning
The version travels in the Accept header as part of a vendor media type:
GET https://api.contoso.com/customers/3
Accept: application/vnd.contoso.v1+jsonThe server answers with a matching Content-Type, for example application/vnd.contoso.v1+json; charset=utf-8. Microsoft calls this mechanism straightforward and well suited to HATEOAS, because links can include the media type of related data. The same caching caveats as header versioning apply.
Date-based versioning
Stripe names each API version by release date, for example 2017-05-24. Every version is backwards-incompatible with the one before, but each contains a small set of changes, so integrations can upgrade incrementally. Stripe pins an account to the newest version when the account makes its first API request, and later calls use that pinned version implicitly. A client can override the version for a single request with the Stripe-Version header, and an account can upgrade its pinned version from the dashboard. Stripe still keeps a /v1 path prefix but does not expect to change it soon.
Behind the scenes, Stripe generates responses in the current structure and then applies a chain of version change modules backwards in time until it reaches the version the client asked for. Each module undoes one backwards-incompatible change. That is the cost of the model: you need a transformation layer and discipline to describe every breaking change as a small, reversible step.
Comparing the strategies
| Strategy | Version appears in | Routing and testing | Cache friendliness | Client effort | Main cost |
|---|---|---|---|---|---|
| URI | Path | Easiest; curl or a browser works | Good; one URL per version | Change base URL | Many versions to keep alive; version in every link |
| Query string | Query parameter | Easy | Good, but some old proxies skip caching query strings | Add a parameter | Parsing and default handling |
| Header | Custom header | Needs header inspection | Weaker; versions can duplicate cached data | Set a header | More server logic; harder to try by hand |
| Media type | Accept header | Needs content negotiation | Weaker, as for header | Set Accept | Content negotiation complexity |
| Date-based | Pinned per account plus an optional header | Needs a translation layer | Depends on design | Often none until the client chooses to upgrade | Building and maintaining version change modules |
How to choose
Use these questions in order.
- Do you control all the clients? For an internal API that you deploy together with its callers, you may need no versioning at all. Microsoft notes this simplest approach can work for some internal APIs, with significant changes represented as new resources or new links.
- Is the API public with many unknown consumers? Prefer URI versioning for major versions. It is visible, easy to document and easy to debug.
- Do you ship frequent small breaking changes? Consider a date-based scheme, but only if you can afford the version translation layer. Stripe's design is a response to nearly a hundred breaking upgrades over several years.
- Is the URI contractually fixed? If URIs must stay stable, use header or media type versioning and plan the caching impact, for example by making sure the version header participates in cache keys.
Whatever you pick, keep it consistent across all endpoints. Mixed schemes inside one API are the hardest to document.
Implement the chosen strategy
The details below use URI versioning because it is the most common choice, but the same ideas apply to the others.
Keep the major version coarse
Version the API as a whole (/v1, /v2) rather than versioning each endpoint independently. A client then picks one version and knows every endpoint behaves consistently. Reserve a new major version for changes that cannot be made additively.
Describe each version in an OpenAPI document
Publish one OpenAPI description per major version and keep the description in source control next to the code. The servers entry below shows how a version can appear in the base URL; the contract file for /v2 is then reviewed in the same pull request as the code that implements it.
openapi: 3.0.3
info:
title: Contoso Customers API
version: 2.0.0
servers:
- url: https://api.contoso.com/v2
paths:
/customers/{id}:
get:
summary: Get a customer
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: The customerGenerating client SDKs and contract tests from one file per version also gives you a mechanical way to compare two versions and detect accidental breaking changes before release.
Default carefully when the client sends no version
Microsoft recommends that a query string version parameter default to a meaningful value, such as 1, for older clients that omit it. Decide deliberately whether "no version supplied" means the oldest supported version (safe for existing clients) or the latest (convenient but dangerous). Defaulting to the oldest keeps existing integrations stable; Stripe's pinning is the date-based equivalent.
Deprecate and retire a version
Introducing a new version is the easy part. The harder part is retiring the old one without surprising clients.
Announce with standard headers
RFC 9745 defines the Deprecation response header. Its value is a Structured Fields date written as @ followed by a Unix timestamp, for example Deprecation: @1688169599. The date can be in the future (deprecation is planned) or in the past (the resource is already deprecated). The header is a hint and does not change how the resource works, so clients should keep working without it.
The Sunset header from RFC 8594 states when the resource is expected to stop responding. It uses an HTTP-date, and its timestamp must not be earlier than the Deprecation date. A Link header with rel="deprecation" points to a migration guide or policy page.
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1788000000
Sunset: Wed, 30 Jun 2027 23:59:59 GMT
Link: <https://contoso.com/docs/api/migrate-v1-to-v2>; rel="deprecation"The timestamp and URL above are placeholders; compute your own Unix timestamp and use your real documentation URL.
Make deprecation observable
- Log the version of every request so you can see who still calls the old version.
- Review those logs before the sunset date and contact active consumers directly.
- Publish a changelog entry for each version change, and a migration guide that maps each old field or endpoint to its replacement.
- After the sunset date, return an explicit error for the retired version rather than silently serving the newer behaviour.
Troubleshooting
Clients break on an additive change. Clients that fail on unknown fields turn every addition into a breaking change. State in your published policy that consumers must ignore unrecognised fields, and test with a client that does.
Cached responses return the wrong version. With header or media type versioning, a shared cache can serve one version's response to a client that asked for another. Make sure the versioning header is part of the cache key, for example through the Vary response header, and test through the proxy or CDN that fronts your API.
Too many versions to maintain. Each additional live version multiplies testing and support. Set a maximum number of concurrently supported major versions in your policy, and put the retirement date on the calendar when you launch the new one.
Links in responses point to the wrong version. URI, query string, header and media type schemes all complicate HATEOAS, because links must carry the version. Build links from the version of the current request rather than hard-coding them.
Clients ignore the Deprecation header. The header is only a hint. Pair it with direct communication and with logging, so you know which consumers have not migrated before the sunset date.
Checklist
- Write a breaking-change policy and publish it, including "ignore unknown fields".
- Prefer additive changes; create a new version only when a change cannot be additive.
- Choose one versioning mechanism for the whole API.
- Keep one OpenAPI document per major version.
- Decide what happens when a client sends no version.
- Send
Deprecation,SunsetandLinkheaders when you deprecate a version. - Log request versions and contact remaining consumers before retirement.
- Set a limit on how many versions you support at once.
For related reading on protecting an API during retries and bursts, see Webhooks that don't double-charge and API rate limiting and Microsoft Graph throttling.
References
- Microsoft Learn, Web API design best practices (Implement versioning): https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design
- Stripe, APIs as infrastructure: future-proofing Stripe with versioning: https://stripe.com/blog/api-versioning
- RFC 9745, The Deprecation HTTP Response Header Field: https://www.rfc-editor.org/rfc/rfc9745
- RFC 8594, The Sunset HTTP Header Field: https://www.rfc-editor.org/rfc/rfc8594
Comments
Questions and tips are welcome. Comments are checked by the EpiFive team before they appear; please do not share passwords, phone numbers or other private details.