Designing REST APIs That Age Well
An API is a contract you have to keep. Get the resources, status codes, pagination, and error shapes right early and clients trust it for years; get them wrong and every change is a breaking one. The conventions that hold up.
The hard part of an API isn't building it — it's that once clients depend on it, you can't casually change it. An API is a contract, and every consumer is a party to it. The design decisions you make in the first week you'll live with for years. Here are the conventions that consistently age well, learned mostly from living with ones that didn't.
Model resources, not actions
REST is organized around nouns (resources), and HTTP verbs say what you're doing to them. The frequent mistake is putting verbs in the URL:
Bad: POST /getUser?id=42
POST /createUser
POST /deleteUser?id=42
Good: GET /users/42
POST /users
DELETE /users/42The verb belongs in the HTTP method, not the path. This isn't pedantry — it makes the whole API predictable. Once a client knows the /users resource, they can guess how to fetch, create, update, and delete without reading docs, because the verbs are standardized.
Use HTTP status codes honestly
The status code is the first thing a client checks, so it must tell the truth. Returning 200 OK with an error in the body is a lie that breaks every well-behaved client and forces everyone to parse bodies to know if a call worked.
- 2xx — it worked.
200(fetched/updated),201(created),204(success, no body). - 4xx — the client messed up.
400(malformed),401(not authenticated),403(authenticated but not allowed),404(not found),409(conflict),422(validation failed). - 5xx — the server messed up. Never blame the client with a 5xx or hide a server fault behind a 2xx.
Paginate from day one
Any endpoint that returns a list will eventually return too many. Bake in pagination before it does, because adding it later is a breaking change to every client that assumed it got everything.
GET /users?limit=20&cursor=eyJpZCI6MTAwfQPrefer cursor-based pagination (a pointer to "where you left off") over offset/page-number for anything large or frequently changing. Offset pagination (?page=5) skips and re-counts rows on every request — slow at scale — and shifts results when rows are inserted mid-scroll, so users see duplicates or gaps. A cursor is stable and cheap.
Give errors a consistent shape
When something fails, return a structured, predictable error body — the same shape every time — so clients can handle failures programmatically instead of scraping strings.
{
"error": {
"code": "validation_failed",
"message": "Email is required.",
"field": "email"
}
}A stable machine-readable code, a human message, and enough detail to act. What you must not do is return a raw stack trace or a different shape per endpoint — that leaks internals and makes robust client handling impossible.
Version before you need to
Put a version in the API from the very first release, even at v1 with no v2 in sight:
GET /v1/users/42The day you need a breaking change, /v2 lets you ship it without stranding existing clients on a broken contract. Retrofitting versioning onto an unversioned API is itself the breaking change you were trying to avoid.
The through-line
Every one of these is really the same principle: an API is a promise, so make it predictable and hard to break. Standard verbs and honest status codes make behavior guessable. Pagination and versioning are escape hatches you install before the fire. Consistent error shapes let clients trust the contract under failure, not just on the happy path. Design for the version of you three years from now who has to change this without breaking a hundred integrations — that's the person these conventions protect.