Skip to content

API Design Best Practices: The Defaults I Argue For Now

API Design Best Practices: The Defaults I Argue For Now

A junior dev on my team shipped an endpoint last year called POST /getUserData. I laughed, and then I went and looked at an API I had built in 2019 and found GET /api/v2/fetchAllProjectsForCurrentUser. So I have no high ground here. We all write bad endpoints, and then we live with them for three years, because renaming one means breaking somebody who depends on it.

That is the real reason API design matters. Not elegance. An API is a promise you cannot easily take back. Once a client depends on a route, that route is close to permanent. I have spent more hours than I would like working around decisions a younger version of me made in about ninety seconds. So this is the short list of defaults I now argue for at the start of a project, while arguing is still cheap.

Make the URL boring and the nouns plural

A REST URL should describe a thing, not an action. The HTTP method is the verb. The path is the noun. When you put a verb in the path, you have usually given up on REST without deciding to.

# endpoints I have written and regretted
POST /getUserData
POST /createProject
GET  /fetchAllProjectsForCurrentUser

# the boring versions that age better
GET    /users/42
POST   /projects
GET    /projects?owner=me

Plural nouns, used consistently. /projects/42, never /project/42. A collection is /projects, a single item is /projects/42, and nesting follows ownership, so a project’s tasks live at /projects/42/tasks. Once that pattern holds across the whole API, people can guess your URLs correctly without opening the docs, and “guessable” is one of the few API compliments that actually means something.

Boring is the goal. A new hire should be able to predict the route for “list a project’s tasks” on the first try, and be right.

One more habit in the same spirit: pick a casing for your JSON fields and never break it. snake_case or camelCase, it does not matter which, but a single API that returns created_at on one endpoint and createdAt on the next forces every client to special-case the difference. While you are deciding, return timestamps as ISO 8601 strings in UTC. Every language parses them, they sort correctly as plain text, and you sidestep a whole category of timezone bugs that are miserable to chase down later. None of this is clever. It is just consistency, and consistency is most of what a pleasant API actually is.

Use the status code, then say it again in the body

The HTTP status code is the first thing every client, proxy, and log line sees. Use it honestly. 200 for a fine read, 201 when you created something, 400 when the caller sent garbage, 401 when they are not logged in, 403 when they are but are not allowed, 404 when it is not there, 500 when you broke. The full list on MDN’s HTTP status reference is worth one slow read, because most APIs use about six codes and a couple of them wrongly.

The mistake I see most is returning 200 with {"success": false} inside. Now every client has to parse the body to learn something the status line should have told it. Monitoring cannot see your error rate without custom parsing. A generic retry layer cannot tell a real failure from a fine response. The status code is the cheap, universal signal. Do not bury it under a 200.

Then repeat the meaning in the body, because a bare 403 tells a developer nothing about what they did wrong or how to fix it.

Errors deserve a real shape

If every endpoint invents its own error JSON, every client writes a different parser, and every parser rots differently. There is a standard for this, and it is worth adopting on day one: RFC 9457, Problem Details for HTTP APIs. It defines a small, predictable JSON object for errors.

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 403,
  "detail": "Your balance is 30 but the transfer requires 50.",
  "instance": "/accounts/123/transfers/abc"
}

type is a stable URL identifying the class of error, the thing your client code can switch on. title is a short human label. detail is the specific message for this one occurrence. status mirrors the HTTP code. instance points at the request that failed.

You do not have to serve the official application/problem+json media type to get the value here. Pick this shape, or one close to it, and use it for every error your API can return. The payoff is that clients write one error handler instead of twelve. I adopted this on a payments integration last year and deleted an embarrassing amount of bespoke error-parsing code in the process.

Pagination is a decision, not an afterthought

Any list endpoint will eventually return more rows than anyone wants in one response. Decide how you will page before you ship the endpoint, because bolting pagination on later is itself a breaking change.

Two options I actually use. Offset pagination is simple and fine for small, fairly stable data:

GET /projects?limit=25&offset=50

It gets unreliable on large or fast-changing tables. Rows shift between requests, so page two can skip or repeat items the user already saw. For anything big or busy, I use cursor pagination instead:

GET /projects?limit=25&cursor=eyJpZCI6MTI4fQ

The cursor is an opaque token that points at “where you stopped”. It stays correct under inserts, and it stays fast no matter how deep into the list a client goes. Either way, return the paging metadata in the response, so the client never has to guess whether more data exists. And if your “list” is really a stream of live updates, pagination may be the wrong tool entirely. I walked through that trade in WebSockets versus server-sent events.

Versioning: pick one and commit

You will make a breaking change one day. Plan the escape route now. The two honest options are a version in the path, like /v1/projects, or a version in a request header. I default to the path. It is a little ugly, and it is visible, and visible is good here: anyone can see which version a request used, you can route it at the load balancer, and a curl command pasted into a bug report carries its own version with it.

Whatever you pick, the rule that matters is this. Adding a field is not a breaking change, so clients must be built to ignore unknown fields. Removing a field, renaming one, or changing its type is breaking, and breaking changes earn a new version. Hold that line and you will cut a new version far less often than you fear at the start.

What I would ship this week

Take one endpoint you already run and read it like a stranger would. Is the path a noun? Does the status code tell the truth on its own, without help from the body? When it fails, does the error JSON match the shape of your other errors? Most APIs I audit, my own included, fail at least one of those three on the first endpoint I check.

Fix that one endpoint. If it is already public and you cannot safely rename it, write the rule down instead, so the next endpoint is born correct. The cheapest moment to design an API well is right now, before anything depends on it. If you want more of how I think about backend structure, that runs through a lot of the work I take on. The endpoint you name carelessly today is the one you will be quietly apologizing for in 2029.