Skip to content

API design best practices I keep going back to

API design best practices I keep going back to

I’ve designed maybe fifteen or twenty internal APIs at this point, across different companies and stacks. I’ve also consumed enough poorly designed ones to know exactly which mistakes feel obvious in hindsight and still get made every time.

The turning point for me was the first time another team had to integrate with something I’d built. Watching them hit the rough edges I hadn’t thought about was educational in a way that code review never quite is. You learn different things when you’re the one being integrated against.

These are the rules I actually go back to every time I’m designing an API from scratch. Not a comprehensive spec — a short list of things that resolve the most common arguments and prevent the most debugging time.

Name resources, not actions

The most common REST API mistake is using endpoints as action names instead of resource names.

# Avoid this
POST /createUser
POST /deleteUser?id=123
GET  /getUserById?id=123
POST /updateUser

# Do this instead
POST   /users
DELETE /users/{id}
GET    /users/{id}
PATCH  /users/{id}

The HTTP method already describes the action. Your endpoint should describe the thing being acted on. Once you internalize this, a lot of design decisions stop being decisions — you’re just modeling your domain as nouns and letting the verbs come from HTTP.

The tricky cases are operations that don’t fit cleanly into CRUD. “Archive an order” — is that a PATCH to set status: archived? A DELETE? A POST to /orders/{id}/archive? I usually go with PATCH and a status field, because it keeps the resource model clean and lets you distinguish archiving from hard deletion at the data layer. The Google API Design Guide has good thinking on custom methods if you hit genuinely complex cases.

Version from the start, even if you think you won’t need to

I’ve never worked on an API that didn’t need versioning at some point. The only question is whether you planned for it.

Versioning in the URL (/api/v1/users) is the most common approach and the most practical for most teams. Verbose but explicit — you can see exactly which version you’re calling, and you can run v1 and v2 side by side during a transition.

Versioning via headers (Accept: application/vnd.api+json;version=2) is technically cleaner but harder to test in a browser or with a basic curl command. In practice, teams that use header versioning spend more time explaining how to set headers correctly than teams that use URL versioning.

Put the version in the URL. Accept that it looks a bit clunky. Future you will be grateful.

Return consistent error shapes

This is the thing that causes the most pain in integrations, and it’s completely avoidable.

Every error response from your API should have the same shape. The shape I use looks roughly like this:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email address is invalid",
    "field": "email"
  }
}

The code is a machine-readable string that clients can switch on. The message is human-readable text for logging or display. Optional fields like field carry context specific to the error type.

What you want to avoid is a mix of shapes across endpoints — sometimes {"error": "something went wrong"}, sometimes {"message": "Invalid input", "errors": [...]}, sometimes a plain string body. This forces every client to handle every possible shape, and they usually do it poorly.

There’s an actual RFC for this — RFC 7807 (Problem Details for HTTP APIs) — if you want a standard to point colleagues to. Pick one shape, document it, and stick to it across every endpoint including 4xx and 5xx responses.

Use 422 for validation errors, not 400

This comes up in code review often enough that it’s worth a dedicated section.

400 (Bad Request) should mean the request is malformed — invalid JSON, wrong Content-Type header, something the server can’t parse. 422 (Unprocessable Content) is for requests that are syntactically valid but semantically wrong: a required field is missing, an email doesn’t match the expected format, a date is in the past when it needs to be in the future.

The distinction matters because clients can handle these differently. A 400 means “fix how you’re sending the request.” A 422 means “fix the data you’re sending.” Conflating them makes client-side error handling more ambiguous than it needs to be.

Same principle applies to 404 vs 403. 404 means the thing doesn’t exist. 403 means the thing exists but you can’t see it. Using 404 when you mean 403 (a common privacy pattern) is a deliberate choice some APIs make — just make it deliberately, not because you mixed them up.

Paginate everything that returns a list

Any endpoint that returns a list should be paginated, even if you currently have five items and can’t imagine having more. Lists grow. APIs that don’t paginate force you to add it later, which is a breaking change if clients are relying on getting everything in one shot.

I default to cursor-based pagination for anything that might have concurrent writes:

{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTIzfQ",
    "has_more": true
  }
}

Offset pagination (?page=2&per_page=20) is simpler and fine for static or append-only data. For anything where items can be inserted or deleted between pages, cursor pagination avoids the skipped-row and duplicate-row bugs that offset pagination produces under concurrent load.

The format matters less than the consistency. Use the same pagination shape across all list endpoints, or your clients will have to write different pagination logic for each one.

Design the error cases before the happy path

I picked this up from a talk I half-remember and can’t find the source for, but it’s changed how I approach API design.

Before writing any endpoint logic, I write down every error case I can think of: what if the resource doesn’t exist? What if the user doesn’t have permission? What if a required field is missing? What if two requests race on the same resource?

Once the error cases are explicit, the happy path usually writes itself. And you end up with a much more complete error surface documented before any client hits a production edge case.

This pairs well with thinking about idempotency keys early — if clients might retry requests (and they will), which endpoints need to be safe to call twice? Better to decide that upfront than to discover an order got charged twice after launch.

For how error handling actually looks in the service layer behind the API, the post on Go error handling patterns covers the approach I use in the services that sit behind these endpoints.

This week: pick one of your own APIs and check whether its error responses have a consistent shape across every endpoint. If they don’t, that’s probably the highest-leverage fix you can make without breaking existing clients.


More on what I build at abrarqasim.com.