Designing APIs you can change later
An API is a contract with people who will not read your changelog. Six design choices that let you keep evolving an interface without breaking consumers.
You will need to change the interface. The goal is to make change cheap rather than to get it right permanently.
This matters more than it sounds, because an API has a property most code does not: you cannot fix a mistake by editing your own repository. Every consumer is a separate deployment schedule, a separate team, and — after long enough — a separate company that may no longer employ anyone who remembers writing the integration.
Six choices that buy room
- Version at the boundary. A version in the path or a header, decided before the first consumer integrates.
- Objects, not bare values. A field returning
{"amount": …, "currency": …}can grow. One returning4200cannot. - Additive by default. New optional fields are safe. Renaming or removing is not, ever, without a version.
- Machine-readable errors. A stable
codeplus a humanmessage. Consumers must never parse prose. - Idempotency keys on writes. Retries are inevitable; duplicate charges should not be.
- Explicit pagination. Cursors, not offsets, once the dataset is large or mutable.
Why each one earns its keep
Versioning at the boundary is cheap on day one and impossible on day four hundred. Adding /v1/ before anyone integrates costs nothing. Adding it afterwards means every consumer changes every URL at once, which is exactly the coordinated migration versioning was supposed to prevent.
The trade-off is real, though: every version you support is a code path you test and a behaviour you cannot quietly fix. Two live versions is manageable. Five means most of your engineering time is spent on archaeology. Version deliberately, and plan the retirement at the same time you plan the release.
Objects instead of bare values is the single highest-leverage habit in this list. 4200 is a number that has already lost its meaning — is it cents, pence, a quantity, a score? Wrapping it costs a few bytes and buys you the ability to add a currency, a precision, a unit or a display hint later without a version bump. The same argument applies to any value that might reasonably grow a qualifier: dates that might need a timezone, IDs that might need a type, statuses that might need a reason.
Additive-by-default only works if consumers tolerate unknown fields. Say so explicitly in the documentation — “clients MUST ignore fields they do not recognise” — because a strict parser on the other end turns your safe, additive change into someone’s outage. If you control the client SDK, enforce it there.
Machine-readable errors are where most APIs quietly fail. A 400 with the message "Invalid request" forces every consumer to either give up or guess. A stable code (insufficient_funds, tenant_suspended, rate_limited), a human message, and — where it applies — a retryable flag and a field path, means a consumer can branch on the failure instead of surfacing your prose to their user. Once a code is published it is part of the contract: you can add codes, you cannot repurpose them.
Idempotency keys are not optional on anything that moves money or creates an obligation. Networks time out after the server has committed. Without a key, the only choices available to the client are “risk a duplicate” or “risk a silent failure”, and both of those become your support ticket. Store the key with the result, return the original response on a repeat, and pick an expiry long enough to cover a client’s retry window.
Cursor pagination avoids the bug every offset-paginated API has and few document: on a mutable dataset, a record inserted while a consumer is paging shifts everything down, so page two silently re-serves a row from page one — or skips one. Offsets are fine for a small, static list. They are wrong for anything a user can add to while an integration is reading it.
Deprecation as a process
Announce, instrument, then remove. Log usage per consumer per version so removal is a decision based on data rather than hope. If you cannot see who is still on v1, you cannot retire v1.
The instrumentation is the part teams skip, and it is the part that makes the rest possible. Record the consumer identity, the version and the endpoint on every request. When you propose a removal, you can then say “four consumers, 300 calls a month, here they are” instead of “probably nobody”. One of those sentences ends a meeting; the other starts a six-month stalemate where the deprecated path stays forever.
A workable sequence: announce with a date and a migration guide; add a deprecation header to responses on the old path; email the identified consumers directly rather than relying on a changelog nobody subscribes to; run a scheduled brownout — short, announced windows where the old endpoint returns an error — so integrations that will break, break on your schedule rather than on removal day; then remove.
What we would not do
Do not version every endpoint independently. It sounds flexible and produces a matrix nobody can hold in their head. Version the surface.
Do not put a version in a field name. email_v2 is a version leak into the data model, and it never gets removed.
Do not expose your database schema as your API. They have different rates of change and different audiences. The moment a column rename is a breaking change for a customer, you have coupled two things that should have been separable.
Do not use GraphQL to avoid making these decisions. It moves the versioning problem — you still deprecate fields, you still need consumer usage data to retire them — while adding query cost analysis and caching problems a REST endpoint does not have. Choose it because clients genuinely need to shape their own queries, not because it promises to make change free.
Every one of these is a default we bring to API work, and the reason mobile clients built alongside them survive a bad connection: the retry semantics were designed in, not discovered in the field.
Sources and further reading
// TAGS
// RELATED POSTS
(02) // LET'S BUILD
START APROJECT
