Backend

REST API Design I Stick To: Status Codes, Pagination, and Errors

Clients should not need tribal knowledge to use your API. These are the REST conventions I enforce on Node services—status codes, pagination, and errors that do not invent a private dialect.

What you will learn

  • Use HTTP status codes honestly; stop stuffing errors into 200
  • Standardize one error envelope with code, message, details, requestId
  • Prefer cursor pagination for large or realtime-ish lists
  • Cap page sizes server-side
  • Add Idempotency-Key for risky POSTs
  • Version publicly and deprecate with a timeline

Resources, not RPC soup

Prefer nouns and HTTP verbs: `GET /orders`, `POST /orders`, `GET /orders/:id`, `PATCH /orders/:id`. When an action is not a clean CRUD shape (`POST /orders/:id/cancel`), that is fine—document it as a sub-resource action—but do not invent `POST /doCancelOrder`.

I inherited an API where everything was `POST /api` with a `method` field in JSON. Debugging required reading application logs for every 200 response that contained `{ ok: false }`. HTTP already has a status line; use it.

Consistency beats purity. If your team already uses `/v1/users/:id/deactivate`, keep the pattern. The crime is mixing RPC, REST, and random verbs in one surface.

Collect related routes under clear prefixes: /v1/billing, /v1/catalog. Gateway routing and docs generation both get easier. Flat bags of unrelated endpoints at the root become impossible to navigate by month six.

Plural nouns for collections keep clients predictable: /users not /user. Singular resources by id sit underneath. Bike-shedding plurals is less costly than mixing both in one API.

Status codes that mean what they say

200 for successful GET/PATCH with a body. 201 for created resources, ideally with Location. 204 for successful DELETE with no body. 400 for malformed input. 401 unauthenticated. 403 authenticated but not allowed. 404 missing. 409 conflict. 422 when JSON parses but business validation fails (I use 400 if the team hates 422—pick one and stay consistent). 429 when rate limited. 5xx only for unexpected server failure.

Returning 200 with `{ error: "..." }` trains clients to ignore status codes. I have done it under deadline pressure; every time it came back as a support tax.

For batch endpoints, avoid inventing a special “multi-status everything is fine” unless you truly need 207. Most product APIs are better with one resource per request and clear codes.

204 responses should truly have empty bodies. Some HTTP clients mishandle 204 with JSON. Be strict in tests. Likewise, never return 201 without creating something durable.

  • Do not overload 200 for failures
  • Use 401 vs 403 correctly—clients handle them differently
  • Prefer 404 over 403 for private resources if you want to avoid existence leaks—document the choice
  • Include Retry-After on 429 responses

Error bodies clients can parse

One shape everywhere: code, message, details, requestId. Machine-readable `code` stays stable; `message` can be human-friendly. `details` holds field-level validation arrays. `requestId` ties to logs.

I reject free-form string errors as the only payload. Mobile apps and web clients both need to branch on `code` without regexing English sentences.

Map unexpected exceptions to 500 with a generic message and log the stack keyed by requestId. Leaking SQL or filesystem paths in error.message is a gift to attackers and a nightmare in screenshots.

Localization belongs in the client when possible. Return stable codes and optionally a default English message. Translating every error on the server couples releases to copy changes.

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Email is invalid",
    "details": [
      { "field": "email", "issue": "format" }
    ],
    "requestId": "req_01J5..."
  }
}

Pagination: why I default to cursors

Offset pagination (`?page=3&limit=20`) is easy and breaks under inserts: page 2 shifts while the user scrolls. For feeds and large tables I use cursor pagination: opaque `next_cursor`, stable sort key (usually `(created_at, id)`).

Always cap `limit` server-side (I use max 100). Return `has_more` or a null cursor when done. Document sort order; changing it later breaks clients mid-scroll.

Offset is still fine for small admin tables with stable ordering and low write rates. Do not dogma your way out of a simple UI.

Cursor tokens should be opaque and signed or encrypted if they embed internal IDs and timestamps. Clients must not be able to mint arbitrary offsets into other tenants’ data.

Include the limit the server actually applied in the response. Clients that ask for 1000 and silently receive 100 will mis-render “end of list” unless you tell them what happened.

// GET /items?limit=20&cursor=eyJpZCI6IjEyMyJ9
{
  "data": [ /* ... */ ],
  "paging": {
    "next_cursor": "eyJpZCI6IjE0NSJ9",
    "limit": 20
  }
}

// SQL sketch (Postgres)
// WHERE (created_at, id) < ($cursorCreatedAt, $cursorId)
// ORDER BY created_at DESC, id DESC
// LIMIT $limit

Idempotency and partial updates

POST create endpoints that can be retried by flaky mobile networks need Idempotency-Key. Store the key with the response for 24h. PATCH is partial; PUT replaces. Mixing them casually is how we duplicated charges once when a client retried a non-idempotent POST.

Document which fields are optional on PATCH. Silent no-ops on unknown fields are safer than hard failures for forward-compatible clients—but log unknowns during migrations so typos do not vanish.

For webhooks you emit, use the same error and versioning discipline. Partners will copy your style. Inconsistent outbound and inbound conventions multiply support load.

Versioning without drama

I prefer URL prefixes (`/v1/`) for public APIs because they are obvious in logs and gateways. Header versioning works but gets lost in support tickets. Do not break v1; add fields tolerantly and remove only in v2.

Deprecation means a header + changelog date + time for clients to migrate—not a surprise 404 on Monday morning.

Internal-only APIs can be stricter. Public partner APIs need a compatibility promise written down. If it is not written, partners will assume forever.

Publish an OpenAPI document generated from the source of truth—or carefully maintained by hand if generation is immature. Stale docs are worse than sparse docs because they create confident wrong clients.

When v2 launches, keep v1 online with a published sunset date. Overlap beats a flag day. I budget engineering time for dual-running, not only for building v2 features.

Filtering, sorting, and sparse fieldsets

Allowlist sort fields. Never pass raw client strings into ORDER BY. The same goes for filter operators—whitelist equals, in, and a few ranges. Open-ended query languages belong in analytics tools, not public product APIs.

Sparse fieldsets (?fields=id,name) help mobile clients when payloads grow. Implement them only when measured payload size hurts; premature field selectors complicate caching and authorization checks.

  • Allowlist sort and filter fields server-side
  • Keep default payloads small; add expand parameters intentionally
  • Document default sort order next to each list route
// Allowlisted sort example
const SORTS = {
  created_at: { column: "created_at", dir: "desc" },
  name: { column: "name", dir: "asc" },
};

function resolveSort(param) {
  return SORTS[param] || SORTS.created_at;
}

Key takeaways

  • Use HTTP status codes honestly; stop stuffing errors into 200
  • Standardize one error envelope with code, message, details, requestId
  • Prefer cursor pagination for large or realtime-ish lists
  • Cap page sizes server-side
  • Add Idempotency-Key for risky POSTs
  • Version publicly and deprecate with a timeline

About the author

Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.

More about Ram · Contact

← GitHub Actions CI/CD for Node Apps: Test, … Next: SQL vs NoSQL: A Decision Guide From Real P… →