Versioning and error shapes
How to evolve an API without breaking clients, and the error format that makes failures actionable.
Strategies for change
| Strategy | Looks like | Trade-off |
|---|---|---|
| URI versioning | /v2/orders | Obvious and cacheable; the version is baked into every URL |
| Header versioning | Accept: application/vnd.example.v2+json | Cleaner URLs; harder to test in a browser |
| Query parameter | ?version=2 | Easy to add; easy to forget |
| No version, evolve only | Additive changes and tolerant readers | Cheapest, but needs discipline on both sides |
- Additive changes are safe: new optional fields, new endpoints, new enum values a tolerant client ignores.
- Breaking changes are not: renaming or removing a field, changing a type, changing what a status code means, tightening validation.
- Prefer extending an existing version over creating a new one; every live version is a permanent maintenance surface.
- Announce removal in stages: document deprecation, add a
Deprecationheader, warn in responses, then retire after the published window.
# deprecated endpoint: tell clients before you remove anything
HTTP/1.1 200 OK
Deprecation: Sat, 01 Aug 2026 00:00:00 GMT
Sunset: Mon, 01 Feb 2027 00:00:00 GMT
Link: <https://api.example.com/v2/orders>; rel="successor-version"One error shape, everywhere
Every failure should look the same, so a client can handle errors generically and add special cases only where it must. RFC 9457 (the successor to RFC 7807) defines the application/problem+json media type for exactly this.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
X-Request-Id: 01J8Z4K2M9QH
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields are invalid.",
"instance": "/orders",
"request_id": "01J8Z4K2M9QH",
"errors": [
{ "field": "items[0].quantity", "code": "min_value", "message": "Must be at least 1." },
{ "field": "shipping.address.postcode", "code": "required", "message": "Required." }
]
}| Member | Meaning |
|---|---|
type | A URI identifying the error class — stable across occurrences |
title | Short, human-readable summary of the type |
status | The HTTP status code, duplicated for convenience |
detail | What went wrong for this occurrence |
instance | The specific request that failed |
| Extensions | Additional members such as request_id and errors |
⚠️
Never leak stack traces, SQL fragments or internal hostnames in an error body. Log the detail server-side and return an opaque correlation id — that is what
X-Request-Id is for.Making change survivable
- Publish a contract (OpenAPI) and diff it in CI so an accidental breaking change fails the build.
- Ignore unknown fields when reading; that one habit lets a server add fields without a version bump.
- Treat enums as open sets: a client that crashes on an unseen status is a client that will crash in production.
- Include a correlation id in every response, success or failure, so a support ticket maps to a log line.
- Test the deprecation path: verify that the sunset headers and the successor link actually resolve.
{
"data": { "id": "1042", "status": "shipped" },
"meta": { "request_id": "01J8Z4K2M9QH", "deprecated": ["status_detail"] },
"links": { "self": "/v2/orders/1042" }
}FAQ
Does every API need versioning from day one?
No. A documented policy — additive changes are free, breaking changes require a new version — matters more than a version number in the URL before you have external consumers.
Should 401 and 403 be used consistently?
Yes, and it is worth writing down. 401 means the credentials are missing or invalid; 403 means the credentials are fine and the permission is absent. Surfacing 404 instead of 403 for private resources is a deliberate privacy choice.
Related
Status codes, pagination and filtering HTTP status codes
Last refreshed 2026-09-18.