RESToring an API

Moving between projects, I keep seeing the same mistakes in REST API design — most of them easy to avoid. This article is my attempt to put into words what I think a good REST API should look like.

If none of this is new to you and this is how you’ve been doing things all along, you’re doing great. Keep it up.

Why bother?

If a project has a single full-stack developer doing both the frontend and the backend, there may not be much reason to care.

But once several developers are involved — especially when new people regularly join the team — a consistent and predictable REST API makes both development and onboarding considerably easier.

Resources

A resource is something the frontend, or any other API consumer, wants to retrieve or manipulate. It is the basic building block of REST.

Resource names should be plural nouns whenever multiple instances of that resource can exist. Keep names short and obvious — ideally a single word, without abbreviations or acronyms.

If you really need several words to make the meaning unambiguous, separate them with a delimiter such as a hyphen or underscore. This lets you keep URLs lowercase, which makes them easier to read and avoids ambiguity around capitalization.

Bad Good
license licenses
individualentity individuals
map-coordinate coordinates
competencyMatrix competency-matrices

Domains

A domain is a top-level resource. If one resource belongs to another, reflect that relationship in the URL hierarchy.

If several endpoints share the same prefix, that is usually a good sign that the repeated part deserves to become its own domain.

Bad Good
/individual/card-block /individuals/card/block
/individual/card-info /individuals/card/info

Parameters

Requests use parameters either to address a specific resource or to filter a collection of resources. Parameters can be passed in several places:

  • path parameters — as part of the resource URL;
  • query parameters — after the resource URL, following a question mark;
  • request body — inside the HTTP request itself. Not every method supports this equally well.

Use the same naming convention for all parameter types throughout the API. Frontend developers tend to appreciate camelCase.

Parameter names should also be concise. There is usually no reason to repeat the entity name inside the parameter itself.

Path parameters are a good fit for addressing specific resources. Query parameters are better suited for filters and response-format options.

Bad Good
/individual/search/{query} /individuals?q={query}
/legal/organizations/type/{type} /legal/organizations?type=KT

Avoid unnecessary identifiers in the path. If a child resource already has its own globally unique identifier, including the parent’s ID is often redundant. That relationship is probably already stored in the database anyway.

Bad Good
/users/{user_id}/photos/{photo_id} /photos/{photo_id}

null vs undefined

Don’t confuse the presence of absence with the absence of presence.

JSON has a null value, which explicitly means “there is no value here.” Use null when you actually want to clear a field — for example, to remove a user’s email address.

If a field should remain unchanged, don’t send it at all. Omit it from the request instead of assigning any value to it, including null.

Methods

The HTTP method tells you what action is being performed on a resource.

GET

Reads a resource. GET is both safe and idempotent: repeating the same request should not change server state.

Although HTTP does not outright forbid a request body on GET, its semantics are not generally defined and many servers, frameworks, and intermediaries will not handle it reliably. Use path or query parameters instead.

Status When to use it
200 Resource found
304 Resource has not changed since cache
404 Resource not found

Reads the resource headers. It behaves much like GET, except the server does not return a response body.

HEAD is useful when you want to inspect metadata, estimate the size of a GET response, or simply check whether a resource exists.

Status When to use it
200 Resource found
304 Resource has not changed since cache
404 Resource not found

POST

Creates a resource or performs some other action that does not map cleanly to one of HTTP’s built-in verbs.

POST is not guaranteed to be idempotent. Repeating the same request may fail, or it may create another resource.

Use the path and request body to pass parameters.

Some companies that take a particularly strict approach to security use POST even for fetching filtered lists, simply to avoid putting personal data into query parameters. Instead, the filters are sent in the request body.

This is often unnecessary because TLS protects the query string while it travels over the network as well. It can still make sense, however, if you are concerned about sensitive parameters leaking into access logs.

POST is also a natural choice for starting asynchronous operations that are later tracked using long polling or a subscription.

Status When to use it
201 Resource created
400 Invalid values were provided

PUT

Fully replaces or updates a resource. PUT is idempotent: repeating the same request should not produce a different result from the first request.

Use the path and request body to pass parameters. PUT is typically used to replace the complete representation of a resource at a known URI.

If an individual field has its own business logic, it can also be modeled as a separate subresource and updated with PUT.

PUT /users/{user_id}/status
"active"

PUT can also be used for idempotent resource creation. If the client already knows the identifier, it can send a request to /documents/{id}. Repeating that request replaces the same resource instead of creating another one.

Status When to use it
200 Resource updated and returned in the body
204 Resource updated with no response body
400 Invalid values were provided

PATCH

Partially updates a resource.

PATCH is not necessarily idempotent — that depends on the semantics of the operation. For example, a PATCH request might describe an operation that increments a counter by one. Repeating it would increment the counter again.

Use the path and request body to pass parameters.

PATCH /users/{user_id}
{"status":"active"}
Status When to use it
200 Resource updated and returned in full
204 Resource updated with no response body
400 Invalid values were provided

DELETE

Deletes a resource.

DELETE is considered idempotent. A repeated request may return 404, 200, or 204, but the state of the server should not change further after the resource has already been deleted.

Request bodies on DELETE have roughly the same problem as request bodies on GET: there is no generally defined semantics for them, and support across servers and frameworks is inconsistent. Prefer path and query parameters.

Status When to use it
200 Resource deleted
204 Resource deleted with no body
400 Invalid values were provided

Naming actions

HTTP methods are already verbs. If the action performed by an endpoint is the same as the HTTP method, adding another verb to the URL is redundant.

Method Bad Good
POST /users/create /users
DELETE /deleteNote?id={id} /notes/{id}

Status codes

A status code lets the client make a decision without parsing the response body.

The frontend can use it to decide whether to render data, show a validation error, redirect the user to a login page, and so on.

The first digit tells you the broad class of the result and often gives you a good idea of where the problem is.

You can browse the full collection on HTTP Cats. Here I’ll stick to the ones you’re likely to use every day.

Code When to use it
200 Successful request with a response body
201 A new resource was created
202 A task was accepted for processing
204 Successful request where the status alone is enough
304 Data has not changed
400 Invalid request parameters
401 User is not authenticated
403 Insufficient permissions
404 Resource not found
409 State or data conflict
422 Validation error
429 Rate limit exceeded
500 Internal server error
502 Upstream service error
503 Service temporarily unavailable
504 Upstream service timeout

Don’t try to use every HTTP status code ever invented. Most APIs are perfectly fine with 10–15 of them.

Consistency matters more than squeezing every possible distinction out of the RFCs.

Errors

An HTTP status code helps the client decide what to do, but it is usually not enough to understand — or display — the actual problem.

Use a consistent error format that includes, at minimum, a machine-readable error code and a human-readable explanation.

For example:

{
  "code": "invalid_email",
  "message": "..."
}

The textual code lets the client distinguish between specific failures hidden behind the same HTTP status.

A 400 tells you that something is wrong with the request. invalid_email tells you exactly what.

Error messages should not contain internal implementation details such as raw database errors or stack traces. Apart from being useless to the API consumer, this is unsafe and exposes details about how your backend works.

Pagination

Pagination is a way to split a collection of resources into smaller chunks and return them page by page.

It becomes useful when the database contains a lot of data while the frontend only needs to display a fraction of it at any given time.

There are two common approaches: offset pagination and cursor pagination.

Offset pagination

With offset pagination, the client sends an offset — how many items to skip from the beginning of the collection — and a limit — how many items to return.

Another common pair is page and pageSize, which simply moves the offset calculation to the server.

There is no fundamental difference between the two. Use whichever convention fits your API better.

Offset pagination works well when counting the collection is cheap and new items are not being inserted too frequently.

Cursor pagination

Offset pagination can produce duplicates between pages when the underlying collection changes frequently.

Imagine fetching one page, then having enough new records inserted at the beginning of the list to shift the offsets before you request the next page. Some items from the previous page can now appear again.

Cursor pagination solves this problem.

Instead of saying “skip N records,” the backend returns a batch of items together with a cursor pointing to where the next batch begins. Even if new records appear in the database, that cursor still points to the continuation of the collection you were already reading.

The main downside is that cursor pagination usually does not let you jump directly to an arbitrary page. To reach a particular position, you generally have to follow the cursors through the preceding pages first.