Once an API ships to production, it gains clients. Mobile apps, partners, your own frontend — they all rely on a particular contract. Change a field's format or remove an endpoint, and something somewhere breaks.
Versioning is how you evolve an API without breaking those who already use it.
Version in the URL: why this way
The most common approach is to put the version right in the path:
/api/v1/orders
/api/v2/orders
This is convenient for several reasons: the version is visible in logs, in the browser, in the documentation. The request curl http://localhost/api/v1/orders speaks for itself — you don't need to add headers to know which version you're calling.
There are two popular alternative approaches that create problems in practice:
Version in a query parameter (/orders?version=1) breaks caching. Proxies and CDNs build the cache key from the URL. If the URL is the same for different versions, a client may get a response from the wrong version out of the cache.
Version in a header (Accept-Version: v1) hides the version. It isn't visible in logs, it doesn't show up in the browser's address bar, and routing at the proxy becomes harder.
That's why — version in the URL path. Format: the letter v plus an integer. Integer only: v1, v2. No minor versions, no dates:
/api/v1/orders ✓
/api/v2/orders ✓
/api/v1.2/orders ✗ — a minor version is not needed
/api/2024/orders ✗ — a date-version says nothing about compatibility
/orders ✗ — no /api and no version
/v1/orders ✗ — no /api
Why /api? It's a namespace for business endpoints. Operational paths — /health, /metrics, /ready — live outside it.
When to create a new version
There's one rule: a new version is created only on a breaking change. If a change is backward compatible, it goes into the current version.
At first glance it seems like any change breaks compatibility. But that's not so — there's a large class of changes that clients are obligated to survive without breaking.
The key convention: a client must ignore unknown fields and unknown enum values in a response. This is the foundation of forward compatibility.
If a client is configured to fail when it sees an unfamiliar field (for example, Jackson with failOnUnknownProperties: true), it will break every time a field is added to the API. That's the client's problem, not the API's. Jackson and Spring RestClient ignore unknown fields by default — that's the right default.
What counts as a breaking change
A breaking change is a change that breaks existing clients without any edits on their side:
- Remove or rename a field — the client used to read
customerId, now it's gone. - Change a field's type — it was a
string, now it's anumber. The client parses it differently or crashes. - Remove a value from an enum — the client receives a status that no longer exists in the enumeration.
- Add a required parameter — a request without it starts failing with an error.
- Change the HTTP method — it was
POST /orders, now it'sPUT /orders. - Change the URL —
/ordersbecame/sales-orders. - Change the response code — it was
200, now it's201. Clients that check for the exact code will break. - Change a field's semantics — the
totalfield used to include taxes, now it doesn't. Same data, different meaning. - Tighten validation —
maxLengthwas reduced, and requests that used to pass now get rejected. - Remove an endpoint — the client calls it and gets a 404.
What does not break compatibility
These changes can be made in the current version without a bump:
- Add an optional field to a response — the client ignores it if it doesn't know about it.
- Add an optional query parameter — old clients don't send it, and that's fine.
- Add a new value to an enum — a client that handles unknown enum values correctly won't break.
- Add a new endpoint — nobody is forced to call it.
- Relax validation — requests that used to be rejected now pass. That's only better for the client.
- Add a new error code — clients that don't know this code should have an "unknown error" path.
- Improve the text of an error message — the
detailfield changed, the meaning is the same.
A common mistake: creating a v2 just to add an optional field. That's unnecessary — both clients, v1 and v2, will get the same response, and you'll only take on the burden of maintaining two versions for no reason.
How to run v1 and v2 in parallel
When a breaking change really is needed:
- You create
v2with the new contract. v1keeps working unchanged.- You tell clients that
v1will be retired — through theSunsetheader in responses, through the documentation. - Once clients have migrated (usually 6–12 months) — you remove
v1.
In Spring Boot this looks straightforward:
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
// old contract
}
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
// new contract
}
Under the hood both controllers can use the same use case objects — only the DTOs and the mapping differ. This way v2 doesn't duplicate business logic, it merely presents it in a new format.
In short
- Version in the URL path:
/api/v1/orders. Format —vplus an integer, no minor versions and no dates. - The
/apiprefix is mandatory for business endpoints;/health,/metricslive outside it. - A new version is created only on a breaking change. Non-breaking changes go into the current version.
- A client must ignore unknown fields and enum values in a response — that's the basis of forward compatibility.
- Breaking: removing/renaming a field, changing a type, removing an enum value, changing the HTTP method, URL, response code, tightening validation.
- Non-breaking: adding an optional field, a new enum value, a new endpoint, relaxing validation.
- Version in a query (
?version=1) and in a header are not used. - v1 and v2 live in parallel; v1 is retired with a
Sunsetheader after clients migrate.
What to read next
- REST URLs and resources — how to build
/api/v1/...paths. - Errors per RFC 9457 — adding a new error code is non-breaking.
- JSON and response formats — adding optional fields to a response.