REST API guidelines

REST API guidelines


REST APIs mostly get you a long way just by leaning on HTTP: verbs for CRUD, status codes for outcomes, caching for free. The awkward parts are the things HTTP doesn't say much about — filtering, pagination, how you return totals — and every guideline solves these differently. I went through a handful of the popular ones to see what I actually want to use for my own APIs.

The ones I looked at:

Filtering and sorting

The least standardised part of any REST API. Everyone does it differently:

Guideline Approach Example
JSON:API filter parameter per field GET /articles?filter[author]=1
JSONAPI.net filter with expression language GET /articles?filter=equals(author,'1')
GitHub Field-specific query params GET /issues?labels=bug
Google Field names as query params GET /books?author=Hemingway
React-Admin JSON object in a query param GET /posts?filter={"author":"John"}
Auth0 Lucene query via q GET /api/v2/users?q=email:"john@example.com"

A few observations:

  • Google and GitHub are easy to write and easy to read but they don't really scale to nested filters or value comparisons.
  • JSON:API and React-Admin let you filter on nested properties, but you have to pick your own convention for operators like greater-than or contains.
  • JSONAPI.net's expression syntax is readable and flexible but whether you can implement it depends heavily on what your database supports.
  • Auth0's Lucene queries are the most powerful and also the most coupled — you're basically exposing the query language of your search index, which tends to come back and bite you when the index changes.

Pagination

Guideline Approach Example
JSON:API Page-based GET /articles?page[number]=2&page[size]=10
JSONAPI.net Page-based GET /articles?page[number]=2&page[size]=10
GitHub Cursor-based (Link header) GET /issues?labels=bug
Google Cursor-based GET /books?pagetoken=abc123
React-Admin Page-based GET /posts?page=2&perPage=10
Auth0 Page-based GET /api/v2/users?page=1&per_page=10

Cursor-based is nicer for large or frequently-changing data — no duplicates or skipped rows when things shift between requests. Page-based is easier for UIs that need to show "page 3 of 27". If I have to pick one I tend to go cursor-based and let the client compute display pages if it really wants them.

Response metadata

Totals, counts, next-page links — where do you put them?

Guideline Where Notes
JSON:API Body (meta) No convention for requesting it.
JSONAPI.net Body Same.
GitHub Link header No total count.
Google Body totalItems always included.
React-Admin HTTP headers Leans on standard Content-Range.
Auth0 Body, opt-in includeTotals query param toggles it.

The Auth0 approach is interesting because counting can be expensive and making it opt-in is genuinely good for performance. The downside is the response shape changes between requests, which is annoying on the client.

Personally I like headers for this — the body stays a clean array, and totals are there when you need them without changing the shape.

The parts everyone agrees on

HTTP methods

  • GET — read. Safe, idempotent.
  • POST — create, or anything that doesn't fit CRUD. Not safe, not idempotent.
  • PUT — full replace. Not safe, idempotent.
  • PATCH — partial update. Not safe, idempotent.
  • DELETE — delete. Not safe, idempotent.
  • OPTIONS / HEAD — metadata and preflight.

URI conventions

  • Collection: GET /articles
  • Single resource: GET /articles/{id}
  • Related resource: GET /articles/{id}/author

For actions that don't fit CRUD, make a sub-resource and POST to it:

  • POST /articles/{id}/publish

Resources tend to be kebab-cased (blog-posts) and field names camelCased — GitHub and Auth0 being the main exceptions that go snake_case.

Boolean field names usually carry a prefix:

  • is... for state — isActive, isPublished
  • has... for presence — hasAccess, hasLicense
  • can... for capability — canEdit, canDelete

Timestamps are almost always ISO 8601 and use these three names:

  • createdAt
  • updatedAt
  • deletedAt (for soft deletes)

Status codes

The ones I actually reach for:

2xx200 OK, 201 Created, 202 Accepted, 204 No Content.

4xx400 Bad Request, 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not allowed), 404 Not Found, 405 Method Not Allowed, 409 Conflict, 422 Unprocessable Entity (request was valid JSON but semantically wrong).

5xx500 Internal Server Error, 503 Service Unavailable.

What I'd actually build on top of Elasticsearch

If the backend is Elasticsearch and the query language is already Lucene, Auth0's q parameter makes a lot of sense — you're not inventing a new filter language, you're exposing the one you already have. If the index has fields you don't want to expose, parse and rewrite the query on the way in rather than forwarding it raw.

For paging I'd support both styles: page/size for UIs, and a cursor for anything that iterates. Elasticsearch's scroll or search_after maps well to cursors; you can base64-encode the cursor so clients don't think they can construct one themselves.

Elasticsearch gives you the total count basically for free, so I wouldn't bother making it opt-in. I'd put it in a response header and keep the body a plain array — same shape every time, which is what I want when I'm writing the client.