Designing REST APIs that last
Practical rules for REST APIs that survive years of change: stable contracts, honest status codes, cursor pagination, and versioning you can actually maintain.

Most REST APIs do not die from a single bad decision. They die from a hundred small ones that nobody could undo, because by the time you noticed, three mobile apps and a partner integration were already depending on the mess. The goal of a good API is not cleverness. It is being boring in a way that still works five years from now.
Here is what holds up over time, with the reasoning behind each call.
Model resources, not actions
The single biggest predictor of an API aging well is whether it describes nouns or verbs. A URL like /users/42/deactivate feels natural when you write it, but it pins your design to one specific action. Six months later you need "deactivate but keep the subscription," and now you are inventing /users/42/soft-deactivate and the surface area starts to rot.
Model state instead. A user has a status. You change it.
PATCH /users/42
Content-Type: application/json
{ "status": "deactivated" }This pays off because the same endpoint absorbs new fields without new routes. When you genuinely have an operation that is not a state change (sending an email, triggering a re-index), then a verb endpoint is fine and honest. The rule is not "never use verbs," it is "reach for resources first and notice when you are fighting them."
Pick status codes that tell the truth
A surprising number of APIs return 200 OK with { "error": "not found" } in the body. This forces every client to parse the body to know if the request worked, which means your monitoring, your CDN, and your retry logic are all blind. Use the protocol that already exists.
200for a successful read or update.201for a created resource, with aLocationheader pointing at it.400for malformed input the client can fix.401for "we do not know who you are,"403for "we know, and no."404when the resource does not exist (or when you do not want to admit it exists, for that user).409for conflicts, like a duplicate unique field or a stale update.422when the body parsed fine but failed business validation.429when they hit a rate limit, with aRetry-Afterheader.
The 401 versus 403 distinction matters more than people think. Returning 403 to an unauthenticated user tells an attacker the resource is real. Returning 404 instead of 403 to a logged-in user who lacks access is sometimes the safer choice, because it does not confirm that the record exists.
Make error bodies machine-readable
Humans read your error messages once, during development. Code reads them on every failed request, forever. Give errors a stable shape with a code clients can branch on, not just a string they have to pattern-match.
{
"error": {
"code": "email_already_taken",
"message": "That email is already registered.",
"field": "email"
}
}The code is a contract. The message is for your logs and the occasional human. Never make a client write if (msg.includes("already")), because the day someone fixes a typo in that string, their integration breaks and you will both spend an afternoon finding out why.
Pagination: cursors, not page numbers
Offset pagination (?page=3&limit=20) is the default everyone copies, and it breaks in two ways. It gets slow on large tables because the database still has to count and skip every prior row (a problem no amount of database indexing fully solves), and it skips or duplicates records when items are inserted between requests. If a user signs up while someone is paging through, the page boundaries shift under them.
Cursor pagination fixes both. You sort by something stable and unique, then ask for "everything after this point."
-- First page
SELECT id, email, created_at
FROM users
WHERE created_at < now()
ORDER BY created_at DESC, id DESC
LIMIT 21; -- fetch one extra to know if there's a next page
-- Next page, using the last row from the previous response as the cursor
SELECT id, email, created_at
FROM users
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;The (created_at, id) tuple comparison is the part people miss. Sorting by a timestamp alone is not deterministic when two rows share the same value, so you add the primary key as a tiebreaker. Encode the cursor (base64 the created_at and id) so clients treat it as opaque and you keep the freedom to change what is inside it.
A clean response wraps the data and the cursor together:
{
"data": [ /* ... */ ],
"page": { "next_cursor": "MjAyNi0wNC0xOFQ...", "has_more": true }
}Version at the edge, not everywhere
Versioning is where good intentions go to die. The trap is sprinkling if (version >= 2) checks through your business logic until no one can safely delete anything.
Put the version in the URL prefix (/v1/, /v2/) because it is visible in logs, easy to route, and trivial to explain to a partner over email. Header-based versioning is more "correct" in REST purist terms, but it is invisible in a browser and a pain to debug. Pick visible over pure.
Then keep the version boundary thin. Your /v1 and /v2 handlers should both call the same core service and differ only in how they shape the request and response. A small transformation layer per version is cheap to maintain. Branching logic scattered through your data layer is not.
And resist minting a new version for additive changes. Adding an optional field or a new endpoint does not break anyone, so it does not need /v2. Reserve version bumps for changes that genuinely break existing clients: removing a field, renaming one, or changing the type or meaning of a value.
Treat the contract as the product
The endpoints are the easy part. What clients actually depend on is the shape of your data, and that is where discipline pays off.
Never repurpose a field. If status returned "active" and "inactive" and you now need a third state, add it as a new value, do not overload an old one. If a client built a boolean toggle on those two values, a third value is a breaking change you did not announce.
Be conservative about what you remove and liberal about what you accept. Ignore unknown fields in request bodies instead of rejecting them, so an older client sending a slightly different payload still works. Keep null versus absent meaningful and consistent: decide whether a missing field means "unchanged" or "set to null" in a PATCH, document it, and never flip it.
Write down the contract somewhere executable. An OpenAPI spec that generates your client types (and gets checked in CI against real responses) catches the drift between "what the docs say" and "what the server actually sends" before your users do.
The takeaway
A REST API lasts when it is predictable. Model resources, return honest status codes, give errors a stable machine-readable shape, paginate with cursors, version at the edge, and guard the data contract like it is the real product, because to your clients it is. None of this is exciting, and that is exactly the point: the good kind of lazy is the design you do not have to keep apologizing for.
If you are staring at an API that already went sideways, that is a fixable afternoon, and a fun one to spend together.

