All articles
APIsAPI DesignHTTP13 min read

REST API Design: Constraints, Resources, Versioning and Pagination

What REST actually requires, how to model resources and URLs, pagination strategies, versioning, filtering, errors, HATEOAS and the Richardson maturity model — with production-ready conventions.

The six constraints

REST is an architectural style defined by constraints. Each one buys a specific property, and skipping one costs you that property — which is why “RESTful” APIs that ignore caching and uniformity feel like they are fighting the web.

  1. Client–server — separate concerns so each side evolves independently.
  2. Stateless — every request carries its own context, so any instance can serve it.
  3. Cacheable — responses declare their own cacheability, so intermediaries can help.
  4. Uniform interface — resources, representations, self-descriptive messages, hypermedia.
  5. Layered system — proxies, gateways and CDNs can sit in the middle transparently.
  6. Code on demand (optional) — the server may ship executable code.

Richardson maturity model

LevelWhat it looks likeReality
0 — RPC over HTTPOne endpoint, POST everythingMost 'REST' APIs at internal companies
1 — ResourcesDistinct URLs per thingBig improvement already
2 — HTTP verbs + status codesGET/POST/PUT/PATCH/DELETE, correct codesThe practical target
3 — Hypermedia (HATEOAS)Responses link to available transitionsRare outside of specific domains
Aim for level 2 and be honest about it
Level 2 gets you caching, correct retry behaviour, useful monitoring and no client surprises. Level 3 is intellectually satisfying and almost never pays for itself in a product API where the client is a UI you also build.

Modelling resources and URLs

/v1/customers/v1/customers/{id}/v1/customers/{id}/orders/v1/orders/{id}every resource also has a flat canonical URLNest one level for containment.Beyond that, use query filters: /v1/orders?customerId=42
Nest for containment, then flatten. Deep nesting produces URLs nobody can cache or reuse.
  • Plural nouns for collections: /orders, not /order or /getOrders.
  • Lowercase, hyphenated multi-word segments: /payment-methods.
  • Identifiers in the path, modifiers in the query string.
  • Maximum one level of nesting; anything deeper becomes a filter.
  • Non-CRUD operations become explicit sub-resources: POST /orders/8812/refund.
✓ GET  /v1/orders?customerId=42&status=shipped
✗ GET  /v1/customers/42/orders/shipped

✓ POST /v1/orders/8812/cancel
✗ POST /v1/cancelOrder?id=8812

✓ GET  /v1/orders/8812/items
✗ GET  /v1/customers/42/orders/8812/items/19/product/reviews
Good and bad URL shapes side by side.

Pagination: offset vs cursor

Offset / pageCursor / keyset
Query?page=3&limit=50?after=eyJpZCI6ODgxMn0&limit=50
SQLLIMIT 50 OFFSET 100WHERE (created_at, id) < (?, ?) LIMIT 50
Deep-page costDegrades badly — the DB scans and discardsConstant time, index seek
Jump to page NYesNo
Stable under insertsNo — items shift, you see duplicates or gapsYes
Total countEasy (but expensive)Usually omitted or approximate
GET /v1/orders?limit=50

{
  "data": [ ... ],
  "page": {
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTIyIiwiaWQiOjg4MTJ9",
    "hasMore": true
  }
}
A cursor response that clients can page forever.

Encode the cursor opaquely (base64 of the sort key tuple) so you can change the internal shape later. Always cap limit server-side — an unbounded limit is an availability bug.

Filtering, sorting, sparse fields

GET /v1/orders
  ?status=shipped,delivered      # multi-value
  &createdAt[gte]=2026-01-01     # range operators
  &sort=-createdAt,total         # '-' prefix = descending
  &fields=id,total,status        # sparse fieldset
  &expand=customer               # controlled embedding
  • Whitelist filterable and sortable fields — never pass user input into a query builder.
  • Every sortable field needs an index, and a tiebreaker on the primary key for stable order.
  • expand gives you GraphQL's main benefit at a fraction of the complexity.

Versioning

ApproachExampleVerdict
URL path/v1/ordersMost common, trivially visible, cache- and log-friendly
HeaderAccept: application/vnd.api.v2+jsonPurer, but easy to get wrong and invisible in logs
Query param/orders?version=2Works; muddles the cache key
Date-basedAPI-Version: 2026-08-22Stripe's model — excellent for long-lived public APIs

Version the API, not each endpoint. Better still, avoid the need: adding fields and optional parameters is backward compatible, so most changes should not require a version at all. Reserve a new major version for removing or renaming things, and publish a deprecation timeline with Sunset headers.

Backwards-compatible changes
Safe: adding a response field, adding an optional request field, adding an endpoint, adding an enum value if clients were told to tolerate unknowns. Breaking: removing or renaming a field, changing a type, tightening validation, changing a default.

Errors and validation

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 422,
  "requestId": "3f9a-77cd",
  "errors": [
    { "field": "items[0].qty", "code": "min", "message": "must be >= 1" }
  ]
}
One error shape everywhere, with a stable code and a correlation id.

HATEOAS: worth it?

Hypermedia means the response tells the client what it can do next, so URLs are discovered rather than hardcoded.

{
  "id": 8812,
  "status": "pending",
  "_links": {
    "self":   { "href": "/v1/orders/8812" },
    "cancel": { "href": "/v1/orders/8812/cancel", "method": "POST" },
    "pay":    { "href": "/v1/orders/8812/payments", "method": "POST" }
  }
}

The genuine benefit is that available actions become server-driven — the client stops reimplementing “can this order be cancelled?”. That is worth doing even if you never build a fully hypermedia-driven client. Full HATEOAS, where clients navigate purely by links, mostly is not.

Production checklist

1TLS + CDN2Auth3Rate limit4Validate5Handler6Serialize7Log + trace
The path every public request should travel through.
  • OpenAPI spec generated from code, not maintained by hand.
  • Idempotency keys on every create endpoint.
  • ETag plus If-Match on every mutable resource.
  • Rate limit headers on every response, not just 429s.
  • Cache-Control: no-store on anything authenticated; Vary everywhere else.
  • Hard caps on limit, body size, and query complexity.
  • A request id propagated into every log line and returned in every error.
  • Deprecation policy: Sunset header, changelog, at least one release of overlap.

Interview framing

“Resources are plural nouns with one level of nesting and flat canonical URLs, so responses stay cacheable. Lists use keyset pagination with opaque cursors because offset pagination degrades and skips rows under concurrent inserts. Versioning is in the path, but we treat additive changes as non-breaking so v1 lives for years. Writes carry idempotency keys, mutable resources use ETags with If-Match for optimistic concurrency, and errors are problem+json with a stable code and the request id that also appears in our traces.”

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free