Versioning and error shapes

How to evolve an API without breaking clients, and the error format that makes failures actionable.

Strategies for change

StrategyLooks likeTrade-off
URI versioning/v2/ordersObvious and cacheable; the version is baked into every URL
Header versioningAccept: application/vnd.example.v2+jsonCleaner URLs; harder to test in a browser
Query parameter?version=2Easy to add; easy to forget
No version, evolve onlyAdditive changes and tolerant readersCheapest, 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 Deprecation header, 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." }
  ]
}
MemberMeaning
typeA URI identifying the error class — stable across occurrences
titleShort, human-readable summary of the type
statusThe HTTP status code, duplicated for convenience
detailWhat went wrong for this occurrence
instanceThe specific request that failed
ExtensionsAdditional 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.

Status codes, pagination and filtering HTTP status codes

Last refreshed 2026-09-18.