API design you will not have to version twice
An API is a promise you cannot take back. Once someone integrates, every field you named, every error shape you returned and every implicit ordering becomes something they depend on — usually including the parts you considered internal detail.
Part ofCustom Software Development
Internal code can be refactored freely because you can see every caller. An API's callers are other people's systems, changed on their schedule, and some of them will not be updated for years. That single asymmetry is what makes API design a different activity from ordinary interface design.
What actually counts as a breaking change
The list is longer than most teams assume, and the surprises are at the bottom.
Pagination: offsets break under writes
Offset pagination is easy to implement and quietly wrong on any dataset that changes while being read. A record inserted between page requests shifts everything down, so one item is skipped; a deletion causes one to repeat.
Offset pagination remains fine for a static reference list or a human browsing a small admin table. It is not fine for anything a machine reads to stay in sync, which is most API traffic.
Errors a client can act on without parsing prose
An error is an API response like any other and deserves the same design attention. It needs a machine-readable code, enough detail to fix the request, and nothing that leaks internals.
- A stable code the client branches on — never the message, which you will want to reword
- Per-field detail for validation, so a form can attach errors without guessing
- A request identifier echoed in your logs, which turns a support conversation into a lookup
- Correct status codes — 400 for a malformed request, 401 unauthenticated, 403 unauthorised, 404 absent, 409 conflict, 422 semantically invalid, 429 rate limited
- No stack traces, SQL or internal hostnames, which are both an information leak and useless to the caller
Idempotency keys, for every unsafe operation
Networks fail after the server has acted but before the response arrives. The client cannot distinguish that from a request that never landed, so it retries — and without idempotency the retry creates a second order, or a second charge.
Version, but design so you rarely need to
Versioning is a cost, not a feature: every live version is code you maintain and test. The goal is an API that absorbs most change additively, with versioning reserved for the changes that genuinely cannot be.
- Add fields rather than repurposing existing ones; an unused field is cheaper than a changed meaning
- Return objects rather than bare values, so there is somewhere to add later —
{ "count": 12 }not12 - Treat every enum as open from the first release
- Never remove a field in place: deprecate, announce, measure real usage, then remove in a version
- Keep at most two live versions, with a published end date for the older one
Before the first external caller
- Write a client against it yourself, in a different language, without reading the source.
- Confirm every list endpoint paginates by cursor and every unsafe endpoint accepts an idempotency key.
- Check that no response leaks another tenant's data given a guessed identifier — the most common real vulnerability in an API.
- Rate-limit per caller, and return the limit, remaining count and reset time in headers.
- Publish a machine-readable schema, generated from the implementation rather than maintained beside it.
The last point matters more than it sounds. Hand-maintained documentation diverges from the implementation within a quarter, and an API whose documentation is wrong is worse than one with none — callers trust it, build against it, and discover the discrepancy in production.
Questions
What counts as a breaking API change?
More than most teams assume. Removing or renaming a field, making an optional field required, changing a type — including 123 to "123" — narrowing accepted values, and turning a synchronous operation asynchronous are all breaking. So are two that surprise people: adding a value to an existing enum, because clients switch exhaustively, and changing default sort order, because someone depends on it whether you documented it or not.
Why use cursor pagination instead of offsets?
Because offsets break under concurrent writes. A row inserted while a client is paging shifts everything down, so one record is silently skipped and never arrives — with no error reported. A cursor encodes the sort key of the last row seen, so the next page resumes from a position rather than a count. Offsets remain fine for static reference data or a human browsing a small table.
What should an API error response contain?
A stable machine-readable code the client branches on, a human message the client never parses, per-field detail for validation failures so a form can attach errors, and a request identifier echoed in your logs. Never stack traces, SQL or internal hostnames. Use 404 rather than 403 for a record belonging to another tenant — 403 confirms the record exists, which is an enumeration leak.
Do I need idempotency keys?
For every POST and PATCH that changes state, yes. A network can fail after the server acted but before the response arrived, and the client cannot distinguish that from a request that never landed — so it retries, creating a second order or a second charge. Store the key with its response, return the stored response verbatim on repeat, and return 422 if the same key arrives with a different body.