REST APIs That Age Well
A good API is boring. Endpoints behave the way their shape suggests, errors arrive in a form clients can act on, and adding a feature next quarter does not break anyone this quarter.
Most of that is decided early, in the resource model. The decisions that hurt later are the ones that felt like details at the time.
Model resources, not procedures
The reliable smell is a verb in a path. POST /createUser, POST /getUserOrders, POST /updateUserEmail — each one is a function call wearing an HTTP costume, and the set grows without limit because nothing constrains it.
Resources constrain it. POST /users, GET /users/{id}/orders, PATCH /users/{id} cover the same ground and the shape tells you what the next endpoint will be called.
Some operations genuinely are not CRUD — publishing, cancelling, refunding. Those are worth modelling as their own resource rather than contorting them:
POST /orders/{id}/cancellation
A cancellation is a thing that exists, has a timestamp and a reason, and can be queried later. Modelling it as a resource gets you that history for free; a cancelOrder endpoint gets you a state change and no record.
Let HTTP semantics mean something
The methods carry guarantees, and clients, proxies, and retry logic rely on them:
| Method | Safe | Idempotent | Repeat is harmless |
|---|---|---|---|
GET | yes | yes | yes |
PUT | no | yes | yes |
PATCH | no | not inherently | design it so |
DELETE | no | yes | yes |
POST | no | no | no |
The consequential row is POST. Because it is not idempotent, a client that times out mid-request cannot safely retry — it has no way to know whether the order was placed. For anything that creates a resource with real-world effect, accept a client-supplied idempotency key and return the original result on a repeat. It costs one indexed column and removes an entire category of duplicate-charge bug.
Status codes clients can branch on
A small, consistently applied set beats an exhaustive one used loosely:
200— succeeded, body attached.201— created, with aLocationheader pointing at it.204— succeeded, deliberately no body.400— the request is malformed.401/403— not authenticated versus authenticated but not allowed. These are different and conflating them makes debugging miserable.404— no such resource.409— conflicts with current state.422— well-formed but semantically invalid.
The anti-pattern worth naming is 200 OK carrying { "success": false }. It forces every client to parse the body before knowing whether the call worked, defeats every piece of middleware that reads status codes, and makes error rates invisible in monitoring.
Pagination that survives writes
Offset pagination is the obvious approach and it quietly breaks under concurrent writes. If a row is inserted before the current offset between two requests, one record shifts across the page boundary and the client never sees it. On a busy collection this is not rare.
Cursor pagination encodes a position in the ordering rather than a count:
GET /orders?limit=50&after=eyJpZCI6IjAxSDgifQ
{ "data": [ /* ... */ ], "page": { "next": "eyJpZCI6IjAxSjIifQ", "hasMore": true } }
The cursor should be opaque to the client — encode it, so its internals stay yours to change. Sort on something stable and unique; a timestamp alone will drop or duplicate records whenever two rows share it.
Offset is still fine for small, stable collections. It is choosing it by default that hurts.
Errors are an interface
An error is a response format like any other, and clients need to branch on it programmatically. That means a stable machine-readable code, not just prose:
{ "error": { "code": "insufficient_funds", "message": "The account balance is too low for this transfer.", "details": [ { "field": "amount", "issue": "exceeds_available_balance" } ] } }
The code is the contract and must not change once published. The message is for humans and can be reworded freely. Keeping those separate means you can improve error text without breaking anyone's error handling.
For validation failures, return all the problems at once. Returning the first one turns form submission into a guessing game.
Version before you need to
The cheapest time to add versioning is before there are clients. Retrofitting it once third parties depend on you is a migration project.
Most changes need not be breaking. Adding a field, adding an endpoint, adding an optional parameter — all safe if clients are told to ignore unknown fields. Reserve a version bump for genuine breaks: removing a field, changing a type, tightening validation, changing what an existing call does.
Wrap-up
Consistency beats cleverness. Model nouns, honour the method semantics, use a small set of status codes honestly, paginate on cursors, and treat error codes as part of the published contract.
None of this is novel, which is the point. An API nobody has to think about is one doing its job.