Skip to content
Software delivery6 min read

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.

ChangeBreaking?
Adding an optional request fieldNo
Adding a response fieldNo — unless a client validates strictly, which some do
Adding a new endpointNo
Removing or renaming any response fieldYes
Making an optional request field requiredYes
Narrowing an accepted value rangeYes
Changing a field's type, including 123 to "123"Yes
Adding a new value to an existing enumYes, in practice — clients switch exhaustively
Changing default sort orderYes — someone depends on it whether you documented it or not
Making a synchronous operation asynchronousYes
Tightening validation to reject what you used to acceptYes, and usually unannounced

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.

text
Offset — page 1 reads rows 1-20. A new row is inserted at the top.
Page 2 reads rows 21-40, but what was row 20 is now row 21: the
client never sees it. On a sync job, that record silently never
arrives, and nothing reports an error.

Cursor — the client sends back an opaque marker for where it stopped.

  GET /orders?limit=20
  { "data": [...], "next_cursor": "eyJpZCI6IjAxSjIifQ" }

  GET /orders?limit=20&cursor=eyJpZCI6IjAxSjIifQ

The cursor encodes the sort key of the last row, so the next page
resumes from a position rather than a count. Inserts and deletes
elsewhere in the set do not shift it.

Requirements: a stable total ordering (a unique tiebreaker after
your sort column), and an opaque cursor — clients must not parse it,
or its format becomes part of your contract.

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.

json
{
  "error": {
    "code": "validation_failed",
    "message": "The request contained invalid fields.",
    "details": [
      { "field": "email", "code": "invalid_format",
        "message": "Must be a valid email address." },
      { "field": "quantity", "code": "out_of_range",
        "message": "Must be between 1 and 100." }
    ],
    "request_id": "req_01J2X8Q"
  }
}
  • 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.

text
POST /payments
Idempotency-Key: 7f3c1a90-5c2e-4c15-9f0a-2b7d8e5f1a44

Server behaviour:
  1. Look up the key. If present, return the stored response
     verbatim — same status, same body. Do not re-execute.
  2. If absent, execute, then store the key with its response
     before replying.
  3. Scope keys per caller, and expire them after 24 hours.
  4. If the same key arrives with a DIFFERENT body, return 422.
     That is a client bug, and silently honouring either version
     hides it.

GET, PUT and DELETE are idempotent by definition and need no key.
POST and PATCH do.

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.

ApproachTrade-off
URL path — /v1/ordersObvious, cacheable, easy to route; duplicates the surface
Header — Accept: application/vnd.api.v1+jsonClean URLs; easier to forget, harder to debug by hand
Date-based per callerFine granularity; the most implementation work by far
No versioning, additive onlyCheapest — viable for longer than most teams expect
  • 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 } not 12
  • 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

  1. Write a client against it yourself, in a different language, without reading the source.
  2. Confirm every list endpoint paginates by cursor and every unsafe endpoint accepts an idempotency key.
  3. Check that no response leaks another tenant's data given a guessed identifier — the most common real vulnerability in an API.
  4. Rate-limit per caller, and return the limit, remaining count and reset time in headers.
  5. 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.

Want this applied to your systems?

Most of what is described here begins as an assessment: a fixed-scope review producing a prioritised written report you can act on with any supplier.

Prefer email? hello@kornspire.com