API versioning: how to change an API without breaking your customers
The hard part of versioning is not the URL scheme. It is agreeing on what counts as breaking, and then actually retiring the old version.
Good REST design means resource-oriented URLs with plural nouns, HTTP verbs used for their defined semantics, honest status codes, a single consistent error shape, cursor pagination for large collections, and idempotency keys on operations that create things. Consistency across endpoints matters more than any individual rule.
| Intent | Good | Avoid |
|---|---|---|
| List orders | GET /orders | GET /getAllOrders |
| One order | GET /orders/{id} | GET /order?id=123 |
| Nested resource | GET /orders/{id}/items | GET /orderItems?order=123 |
| Action that is not CRUD | POST /orders/{id}/refunds | POST /refundOrder |
| Filter | GET /orders?status=open | GET /openOrders |
Plural nouns, lowercase, hyphens between words, no file extensions, no verbs. When an operation genuinely is not CRUD — refunding, publishing, cancelling — model it as a sub-resource you create rather than inventing a verb path.
A small, honest set: 200, 201, 204, 400, 401, 403, 404, 409, 422, 429 and 500.
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields are invalid.",
"instance": "/orders",
"errors": [
{ "field": "email", "code": "invalid_format", "message": "Enter a valid email address." }
],
"requestId": "req_01HQ8Z3F2K"
}One shape for every error in the API. Clients write one handler; support can trace any report by request id; and machine-readable codes let the UI decide what to do without string-matching human text.
Offset pagination is simple and wrong for data that changes. If a row is inserted while a client is paging, page two silently repeats or skips records. Cursor pagination — an opaque token pointing at a stable sort position — avoids that and performs better at depth because the database does not count past rows.
GET /orders?status=open&limit=50&cursor=eyJpZCI6IjAxSFE4WiJ9
200 OK
{
"data": [ ... ],
"page": { "nextCursor": "eyJpZCI6IjAxSFE5QSJ9", "hasMore": true }
}GET, PUT and DELETE are idempotent by definition. POST is not, which is why every payments API supports an Idempotency-Key header: the server stores the result against the key and returns the same response if the request is repeated.
URL versioning (/v1/orders) is the pragmatic default: visible in logs, easy to route, trivially testable in a browser. Header versioning is cleaner in theory and harder in practice.
Strictly yes, practically almost nobody implements it and clients almost never consume it. A well-documented, consistent resource API delivers the value; hypermedia links are optional.
PUT replaces the whole resource; PATCH applies a partial change. Most product APIs want PATCH. If you support PUT, be honest that omitted fields are cleared.
Generate the specification from the code or generate the code from the specification — never maintain both by hand. An OpenAPI document that is not derived from reality is worse than no document.
Harshal Patel
Founder & Lead Engineer, ROVQIX
Harshal leads engineering at ROVQIX, where he has shipped production Next.js, Node.js and PostgreSQL systems for startups, SaaS teams and ecommerce brands. He writes about the trade-offs behind architecture decisions rather than the framework of the week.
ROVQIXdesigns and builds production web platforms — Next.js front ends, Node.js APIs and the infrastructure behind them. Tell us what you're building and we'll scope it with you.
The hard part of versioning is not the URL scheme. It is agreeing on what counts as breaking, and then actually retiring the old version.
Rate limiting is not just abuse prevention. It is the mechanism that stops one client's bad afternoon from becoming everyone's outage.
GraphQL solves a real problem for a specific shape of team. If you are not that team, it is a large bill for flexibility you will not use.
No spam. Just the occasional case study and craft breakdown.