Status codes, pagination and filtering

The status codes an API should return, offset versus cursor pagination, and the query parameters clients expect for filtering, sorting and fields.

Codes clients actually branch on

CodeReturn it when
200 OKA successful read or update with a body
201 CreatedA resource was created — include Location
202 AcceptedWork is queued asynchronously — include a status URL
204 No ContentSuccess with nothing to say (DELETE, PUT)
304 Not ModifiedConditional GET and the ETag still matches
400 Bad RequestMalformed syntax, failed parameter validation
401 UnauthorizedMissing or invalid credentials
403 ForbiddenAuthenticated but not allowed
404 Not FoundNo such resource
409 ConflictVersion or uniqueness conflict
412 Precondition FailedIf-Match did not match the current ETag
422 Unprocessable ContentWell-formed but semantically invalid
429 Too Many RequestsRate limited — send Retry-After

A client should be able to decide what to do from the status line alone: retry, re-authenticate, or stop. Returning 200 for a failure forces every consumer to parse the body before it knows whether anything worked.

Offset versus cursor pagination

Offset / pageCursor / keyset
Request?page=3&per_page=50?cursor=eyJpZCI6MTA0Mn0
Stable while data changesNo — rows shift between pagesYes
Random page jumpsYesNo, forward (and sometimes back) only
Deep-page costGrows: the database still walks the skipped rowsConstant, with the right index
Best forAdmin tables and reportsFeeds, exports, high-volume lists
GET /orders?status=shipped&per_page=50&page=3 HTTP/1.1

HTTP/1.1 200 OK
Link: <https://api.example.com/orders?status=shipped&per_page=50&page=4>; rel="next",
      <https://api.example.com/orders?status=shipped&per_page=50&page=9>; rel="last"
X-Total-Count: 412

{ "data": [ { "id": "1042" } ], "meta": { "page": 3, "per_page": 50, "total": 412 } }
GET /orders?status=shipped&limit=50&cursor=eyJpZCI6MTA0Mn0 HTTP/1.1

HTTP/1.1 200 OK
Link: <https://api.example.com/orders?status=shipped&limit=50&cursor=eyJpZCI6MTA5Mn0>; rel="next"

{ "data": [ { "id": "1042" } ], "next_cursor": "eyJpZCI6MTA5Mn0" }
⚠️
Encode the cursor so clients do not depend on its contents, and always bind it to the same filter and sort. Reusing a cursor with different parameters silently returns the wrong page.

Filtering, sorting and field selection

GET /orders?status=shipped&created_after=2026-01-01
GET /orders?status=shipped&status=refunded        # repeated = OR
GET /orders?sort=-created_at,total                # - means descending
GET /orders?fields=id,status,total                 # sparse fieldsets
GET /orders?include=customer,items                 # related resources
GET /orders?q=laptop+stand                         # free-text search
  • Define AND across different parameters and OR within a repeated one; without a rule, clients guess differently.
  • Whitelist sortable fields. An unsortable column should be a 400, not a slow query.
  • Put the paging parameters and the filters in the Link URLs you emit, so a client can follow them without reconstructing the query.
  • Cap per_page and limit. A missing limit is how one client takes down a database.
  • Version the response envelope — data plus meta — rather than returning a bare array, so you can add fields later.

FAQ

Which pagination should a new API use?
Cursor pagination for anything that grows continuously; offset pagination when users need to jump to a page number. Several mature APIs offer both, with the offset variant capped.
Should totals be in the body or a header?
Either works, but counting is often the most expensive part of the query. Emit it only when asked, for example with a with_total=true parameter.

Resource modelling and methods Versioning and error shapes

Last refreshed 2026-09-18.