API Design for People Who Will Have to Use It in Two Years
Naming, versioning, errors, pagination and documentation are the interface. Get them wrong and the bill arrives on every team downstream, for as long as the API exists.
On This Page

The person who will resent your API most has not been hired yet. They will arrive in two years, inherit an integration nobody remembers writing, and spend their first fortnight working out why the "status" field is sometimes a string, sometimes a number, and occasionally absent. Nobody will be able to tell them which behaviour is intentional, because the person who chose it left and never wrote it down.
Design for that person. Not for the sprint you are in, not for the one consumer you have today, and certainly not for the demo. Every shortcut taken inside an interface is a tax collected from everyone downstream, in every release, for as long as the endpoint answers.
Five parts of an API decide whether that fortnight happens: naming, versioning, errors, pagination and documentation. None of them are exotic. All of them are settled in the first week by whoever happens to be typing, and all of them become expensive at precisely the moment they are hardest to change, which is when somebody depends on them.
The API design principles below are not a style guide. They are the small set of decisions we would defend in review on any web application build, including the internal ones, because "internal" has never once meant "temporary".
The cost of a bad API is paid downstream
The team that writes an API pays the smallest share of what it costs. They know why the field is called what it is called. They know which endpoint lies about its status codes. Their knowledge is free to them and unavailable to everyone else.
Consumers pay the rest, and they pay it in a form nobody tracks: a wrapper library that exists only to normalise your inconsistencies, defensive parsing around a field that is nullable in practice but not in the docs, a retry loop written because an error came back with a 200, and a mapping layer that translates your names into names their team can say out loud. Multiply that by the number of consumers, then by the number of years the interface survives. That product is the real cost, and none of it appears on the invoice for the original build.
Worse, the shape propagates. A badly named field becomes a badly named column in the consumer’s database, then a badly named property in their domain model, then a confusing label in their user interface. Two years later a designer asks why the button says what it says, and the honest answer traces back to a JSON key somebody typed quickly on a Thursday.
An API is a promise about the shape of the world, made to people who are not in the room when you make it.
Not every interface is an HTTP endpoint
The definition is wider than the word suggests. A webhook payload is an API. A CSV export somebody automates against is an API the moment they schedule it. A repository can be one too: a workspace like Acrosite generates the required files, commits them to GitHub and triggers the configured deployment, which makes the file shape a contract and the build pipeline its consumer. The transport changes. The questions do not: what is the stable shape, what happens when it changes, and how does a consumer find out.
It cuts the other way as well. When you adopt somebody else’s platform you inherit their interface decisions wholesale, which is a large part of what headless commerce actually asks you to take on. You will live inside their naming, their pagination and their idea of an error, and you will not get a vote.
Naming is most of the interface
Names are the part consumers read a thousand times and you read once. They are also the part that cannot be fixed quietly, because renaming a field is a breaking change no matter how obviously wrong the old name was.
Consistency beats local correctness
Pick one convention for each thing and never break it, even where breaking it would be marginally better. One case style for keys. One rule for whether collections are plural. One timestamp format everywhere, with a timezone, in every payload including the ones you added last. The APIs people describe as painful are rarely wrong. They are inconsistent: three date formats, two identifier styles, one endpoint returning a bare array while its neighbour returns an object with a data key.
Inconsistency is expensive because it destroys the ability to guess. A consumer who has used four of your endpoints should be able to predict the fifth. When they can, integration is an afternoon. When they cannot, every endpoint is a fresh negotiation with your documentation, and they will start reading your responses in a browser rather than trusting what you wrote.
Name for the domain, not for the storage
Field names leak whatever the team was thinking about when they wrote them, and what they were thinking about is usually the database. Abbreviations that made sense inside one table make none in a payload. Flags named after the migration that introduced them are archaeology, not vocabulary. Name the concept the consumer holds in their head, then keep that name stable while your storage changes underneath it, which it will.
Two smaller rules save arguments later. Name booleans positively, because a negated name inverts every condition that reads it and somebody will eventually get it backwards. And avoid names that encode a current limitation: a singular field for something the business will plausibly have several of is a rename waiting to happen. The concession here is real, though. You cannot generalise every name against every possible future, and APIs designed that way end up with nothing but generic containers and no meaning at all. Design for the domain you have, plus the one obvious extension you can already see coming.
Errors are part of the API, not an afterthought
Most APIs are documented along the happy path and leave the failure modes to be discovered in production by somebody else. This is backwards. In any integration that matters, the error surface is exercised more often than the success surface, because networks fail, humans send bad input, and rate limits exist.
What every error response must carry
- A status code that means what it says. 4xx when the caller can fix it, 5xx when they cannot. Returning 200 with an error body inside it breaks every generic client ever written.
- A stable machine-readable code. Callers will branch on something. Give them a short constant string, or they will branch on your human-readable message and break when you improve the wording.
- A human message written for a log. It is read by a developer at midnight, not by an end user. Say what was wrong, not that something went wrong.
- The field that failed, for validation errors. A pointer to the offending input turns a support ticket into a two-minute fix on the caller’s side.
- A request identifier the caller can quote. Without one, every support conversation begins with twenty minutes of trying to find the request in your logs.
That request identifier is where the interface meets your own instrumentation. If the identifier a caller quotes cannot be found in a trace within seconds, the field is decorative, which is one of the practical reasons observability matters even on systems that rarely break. Rare failures are exactly the ones nobody has practised diagnosing.
One security note belongs here rather than in a separate document. Errors are an information channel, and an over-helpful one leaks structure: stack traces, table names, whether an account exists. Authentication failures in particular should be uniform, timing included, so a caller cannot distinguish "no such account" from "wrong credentials". Be generous with detail about the caller’s own input, and deliberately dull about anything that describes your internals.
Pagination is a promise you cannot withdraw
Paginate every collection from the first release, including the one that returns eleven rows today. An endpoint that returns everything is a commitment you cannot take back without breaking someone, and collections grow in exactly the way nobody plans for. The day it starts timing out, the fix is a new version rather than a patch.
Then choose the mechanism deliberately. Offset paging is trivial to implement, works with any query, and lets a caller jump to an arbitrary page. It also gives wrong answers under concurrent writes: insert a row while somebody is on page three and they will see a record twice or miss one entirely. Cursor paging is stable across writes and cheap at depth, at the cost of arbitrary jumps and a slightly harder implementation.
For anything a machine consumes in a loop, we would use cursors. For a human-facing admin table that needs a page selector, offsets are usually acceptable, and being honest about that beats pretending otherwise. Whichever you choose, return the next cursor or link in the response instead of asking callers to construct it. A caller who builds their own pagination URL has just made your internal format part of the contract.
A total count on a large filtered collection is often the slowest part of a paginated request, and most consumers only wanted to know whether another page exists. Return a next-page indicator by default and make the exact total something a caller has to ask for. If you have already promised a total on every response, you now have to keep computing it, which is the entire point of deciding this on day one.
Versioning: decide before you need it
Version the API in its first release, when there is one consumer and nothing has changed yet. Retrofitting a version onto an unversioned API is itself a breaking change, which means the cheapest moment to do it is the moment it feels least necessary.
On placement we take a side: put it in the path. A version in the URL is visible in logs, routable at the edge, searchable in a consumer’s codebase, and speakable in a meeting. Media-type negotiation through headers is the more elegant answer and it is harder to debug at three in the morning, which is when the answer needs to be obvious. Established platforms tend to agree; the WordPress REST API namespaces its routes and carries the version in the path, and every client library in that ecosystem is simpler for it.
What counts as a breaking change
Teams argue about this in the abstract and then ship breakage by accident. The test is not whether the change is small. The test is whether a correct existing client keeps working without being touched.
| Change | Breaking? | What to do instead |
|---|---|---|
| Add an optional response field | No | Ship it, and tell clients to ignore unknown fields |
| Add a required request field | Yes | Make it optional with a documented default |
| Rename a field | Yes | Add the new name, keep both, deprecate on a date |
| Change a field’s type or format | Yes | Introduce a new field beside the old one |
| Tighten validation on existing input | Yes, silently | Warn first, log offenders, enforce in the next version |
| Change the default page size | Yes, in practice | Keep the default, let callers opt into a larger one |
| Return a different error code for the same condition | Yes | Add the new code in a new version only |
| Remove an endpoint | Yes | Deprecate with a date, a replacement, and a changelog entry |
Deprecation needs a date, not a label. "Deprecated" with no removal date is a note nobody acts on, and the endpoint is still serving traffic four years later because removing it feels risky. Publish the date, publish the replacement, and if the shape is wrong enough that no additive path leads anywhere, treat it as the rewrite-versus-refactor decision that it actually is, with the same honesty about cost.
Before you set a removal date, name every consumer you cannot force to upgrade: shipped mobile applications, partner integrations, a script on a machine in a warehouse, a customer’s scheduled job. Those set your real deprecation window, not your own release cadence. An API with unreachable consumers is closer to a published standard than to internal code, and it should be changed with that much care.
Documentation is part of the interface
Documentation is not adjacent to the API. For everybody who cannot read your source, it is the API. Undocumented behaviour does not stay undiscovered; it gets reverse-engineered, then depended on, and then it is load-bearing without ever having been a decision anyone made.
A reference generated from the same schema the server validates against will drift less than any prose, so generate what can be generated: fields, types, nullability, enumerated values, status codes. But a generated reference answers "what exists" and never answers "how do I do the common thing". Both are needed, and the second is the one that gets skipped. Write the walkthrough for the two or three tasks most integrations perform, with a complete request, a complete success response, and at least one complete failure.
The other half is the changelog, which is documentation of time rather than of shape. It belongs with the API, published on the same cadence, listing what changed and what a consumer must do about it. On whether a model should draft any of this, we have set out where the line falls in should AI write your product documentation. The short version: generation is fine, verification is not optional, and an example that has never been executed is a bug report waiting to be filed.
It is worth saying plainly that an API is closer to a product than most teams treat it as, with a user, an onboarding experience and a support cost. Our parent company argues the general form of that point in what a company actually sells, and it applies here more directly than almost anywhere else in engineering.
The two-year test, before version one ships
Run this before the first release, in order. It takes a day. Every item on it is something we have watched a team discover much later at considerably greater expense.
- Hand it to someone who did not build it. Give them the documentation and nothing else, then watch where they stop. Every question they ask out loud is a documentation defect, not a them problem.
- Read the field names aloud. Anything you have to explain in a clause is named for your storage rather than for the domain. Rename it now, while renaming is free.
- Write the error catalogue before finishing the happy path. List every condition, its status code, its stable code and its message. Errors invented one at a time never form a coherent set.
- Paginate everything, including the small collections. Decide offset or cursor deliberately and put the choice in the documentation, because callers will build against whichever they observe.
- Put the version in the path and write down what would force the next one. If nobody can name a plausible trigger for version two, the model is probably not finished yet.
- Publish the deprecation policy before you need one. How much notice, announced where, supported for how long. Writing it while calm produces a policy you can actually honour.
- Ship the changelog with the API. A changelog started later is a changelog with a gap in it, and the gap always covers the change somebody is asking about.
The API design principles worth defending, and when to ignore them
If you are building an interface today, spend the first day on names and the error catalogue, and the second on pagination and versioning. Documentation follows the shape, so writing it first is writing it twice. That order is deliberate: the two hardest things to change later are what you called something and what you promised about failure.
Here is where the advice reverses, because it genuinely does. If your API has exactly one consumer, that consumer lives in the same repository, both sides deploy together, and no external party can call it, then versioning ceremony is waste. Refactor freely, rename what is wrong, and enjoy the freedom while it lasts. The rules switch on at a specific and identifiable moment: the day a second consumer appears, or the day either side can ship without the other. Name that moment when it arrives rather than discovering it afterwards.
The opposite failure deserves a mention too. Over-generalised APIs, built to anticipate consumers who never materialise, are their own kind of expensive: generic resource endpoints, a query language nobody asked for, configuration in place of decisions. That design serves an imagined future and inconveniences the actual present. Design for the consumers you have plus the one you can already see, and no further.
Most of what we are describing is not extra engineering work. It is the same work, sequenced differently, which is usually what separates an interface that ages well from one that gets wrapped. If you are joining systems that were never designed to meet, that is the ordinary shape of automation and integration work, and if you already have an API that everybody routes around, it belongs in a product improvement conversation rather than a backlog. Either way, tell us what your consumers keep working around and start there.
Common questions.
What makes an API easy to use two years after it was built?
Predictability, mostly. An API that uses one naming convention, one timestamp format and one response envelope can be guessed after a few endpoints, so a new developer integrates from pattern rather than from documentation. Add a stable error format, pagination on every collection and a changelog that records what changed, and somebody who never met the original team can work productively on their first day.
Should I put the API version in the URL or in a header?
Put it in the URL path unless you have a specific reason not to. A version in the path is visible in server logs, routable at the edge, easy to search for in a consumer codebase, and unambiguous in conversation. Header-based content negotiation is technically cleaner but harder to debug during an incident, which is exactly when clarity has the most value.
What counts as a breaking change in an API?
Any change that stops a correct existing client from working without modification. That includes renaming or removing a field, changing a type or format, making an optional request field required, tightening validation on input you previously accepted, and returning a different error code for an unchanged condition. Adding an optional response field is generally safe, provided clients were told to ignore fields they do not recognise.
Should I use offset pagination or cursor pagination?
Use cursors for anything a machine reads in a loop, and offsets only where a human needs to jump to an arbitrary page. Offset paging returns duplicated or skipped records when the underlying collection changes between requests, and it gets slower the deeper you go. Cursors are stable across writes and cheap at depth, at the cost of arbitrary page jumps and a harder implementation.
What should an API error response contain?
A status code in the correct class, a short stable machine-readable code, a human-readable message written for a developer reading logs, the specific field at fault when the failure is validation, and a request identifier that can be quoted in a support conversation. Never return an error inside a 200 response, and never make callers branch on the wording of your message.
How much API documentation is enough?
Enough that somebody can complete the two or three most common integration tasks without reading your source or asking you a question. That means a generated reference covering every field, type and status code, plus a hand-written walkthrough for each common task with a full request, a full success response and at least one full failure. Then a changelog, published on the same cadence as the API.
Facing this in your
own business?
Tell us where you’re headed — we’ll map the shortest honest route.