RESTful API Design in Practice

Path conventions, status codes that fit, payload shape, pagination, filtering, the bulk-operations friction every REST API hits.

Building Block Foundational
12 min read
rest api http pagination design

What it is#

REST is the architectural style. RESTful API design is the body of accumulated practice for turning that style into endpoints that don’t embarrass you six months later. Fielding’s thesis doesn’t tell you whether to pluralise /users or call it /user. It doesn’t tell you whether 204 or 200 is the right status for a delete. It doesn’t tell you how to paginate or how to express filters. The thesis sets the constraints; the practice fills in the conventions.

This is the practical playbook. Eight surfaces every REST API has to nail:

  1. Path naming (resource-oriented, plural nouns, no verbs).
  2. HTTP verb semantics (GET/POST/PUT/PATCH/DELETE).
  3. Status codes that actually fit the outcome.
  4. Payload shape (request and response envelopes).
  5. Pagination (offset vs cursor, headers vs body).
  6. Filtering, sorting, and sparse fieldsets.
  7. Bulk operations (the place REST loses on throughput).
  8. Versioning and evolution.

The Stripe API, the GitHub REST API, and the Twilio API are the textbook references. When in doubt, look at how they did it.

When to use it#

Reach for RESTful design when you’ve already picked REST as the style — when the consumer is a browser, a polyglot ecosystem, or a partner backend; when resources are the right abstraction; when cacheability and tooling matter. If you’ve picked gRPC or GraphQL, you do not need this playbook — those have their own.

Within REST, the playbook applies to:

  • Public APIs with external integrators. Conventions matter most when you can’t control the caller.
  • Partner APIs where each integration is a multi-week project for the partner. Consistency saves them time.
  • Internal APIs that will outlast the team that built them. A consistent shape is the closest thing to documentation you’ll always have.

The playbook applies less strictly to throwaway internal endpoints and quick prototypes. But if it’s going to live more than a quarter, follow the conventions.

How it works#

Path naming#

The path identifies a resource, not an action. The discipline:

  • Plural nouns. /users, /orders, /invoices. Not /user, not /getUser, not /userList.
  • No verbs in paths. The verb is the HTTP method. POST /orders creates an order; GET /orders/{id} reads one. Never POST /createOrder.
  • Nested resources for true ownership. /users/{id}/orders is fine if orders are owned by a user. /orders?user_id=X is also fine and often better — flat URLs cache and link better, and nesting deeper than two levels (/users/1/orders/2/items/3) becomes unwieldy.
  • Kebab-case in paths. /billing-addresses, not /billingAddresses or /billing_addresses. Browsers, logs, and CDNs all prefer it.
  • Use collective verbs sparingly for non-CRUD actions. When an action genuinely doesn’t fit POST/PUT/PATCH/DELETE semantics, a collective verb after a colon is the convention: POST /orders/{id}:cancel, POST /orders:batchCreate. Google’s API guidelines codified this.
Path naming, side-by-side
# Good
GET /v1/orders
POST /v1/orders
GET /v1/orders/ord_a3f9c2
PATCH /v1/orders/ord_a3f9c2
DELETE /v1/orders/ord_a3f9c2
POST /v1/orders/ord_a3f9c2:cancel
GET /v1/users/usr_7b1d/orders
# Bad
GET /v1/getOrders
POST /v1/createOrder
POST /v1/order/cancel?id=ord_a3f9c2
GET /v1/orderList

HTTP verbs#

The verb taxonomy is the same as in the REST — The Architectural Style page. The discipline in RESTful design:

  • GET is safe and idempotent. No side effects on resource state. (Logging and view counters don’t count.)
  • POST creates a resource the server names (returns Location: /orders/{id}). Non-idempotent unless you use an Idempotency-Key.
  • PUT replaces an entire resource the client names (PUT /orders/{id} with a complete representation). Idempotent.
  • PATCH partially updates. Use JSON Merge Patch (RFC 7396) for simple updates or JSON Patch (RFC 6902) for the explicit operation list. Idempotency depends on the patch semantics; include an If-Match ETag for safe concurrent edits.
  • DELETE removes. Idempotent — DELETE of an already-deleted resource returns the same status as the original delete.

Status codes that fit#

The right code for the actual outcome:

OutcomeStatusBody
Read succeeded200 OKResource
Resource created (POST)201 CreatedResource + Location header
Async accepted, will process later202 Accepted{ "task_id": "..." }
Update / delete succeeded, no body needed204 No Contentempty
Cached representation still valid304 Not Modifiedempty (conditional GET)
Malformed JSON / missing required field400 Bad Requesterror envelope
Missing or invalid credentials401 Unauthorizederror envelope
Authenticated but not allowed403 Forbiddenerror envelope
Resource doesn’t exist404 Not Founderror envelope
State conflict (already-confirmed order being confirmed again)409 Conflicterror envelope
Payload is well-formed but semantically invalid422 Unprocessable Entityerror envelope with field-level details
Rate limit exceeded429 Too Many Requestserror envelope + Retry-After
Server bug500 Internal Server Errorerror envelope (no stack trace)
Upstream timeout / unavailable503 Service Unavailableerror envelope + Retry-After

The rookie mistake is returning 200 OK with { "error": "..." } in the body. Clients that only check the status code will treat the failure as success. Use the right status, every time.

Payload shape#

A consistent envelope across all endpoints. For a single resource:

Single-resource response
{
"id": "ord_a3f9c2",
"object": "order",
"status": "confirmed",
"amount": { "currency": "USD", "value_minor": 4999 },
"created_at": "2026-05-30T08:14:23Z"
}

For a list:

List response
{
"object": "list",
"data": [
{ "id": "ord_a3f9c2", "object": "order", "status": "confirmed", "..." : "..." },
{ "id": "ord_8c4e11", "object": "order", "status": "shipped", "..." : "..." }
],
"has_more": true,
"next_cursor": "eyJsYXN0X2lkIjoib3JkXzhjNGUxMSJ9"
}

For errors — pick one envelope and use it everywhere:

Error response
{
"error": {
"type": "invalid_request_error",
"code": "missing_field",
"message": "The 'shipping_address_id' field is required.",
"param": "shipping_address_id",
"request_id": "req_a8d2..."
}
}

The request_id is the most underrated field in the envelope. It is the integrator’s bridge to your logs. Every response — success and failure — should include it.

Pagination#

Two patterns, both common.

Offset / limit — the easy one:

GET /v1/orders?offset=40&limit=20

Returns 20 orders starting at the 41st. Simple to implement, simple to call. Two problems: it gets slow on deep pages (the database scans offset + limit rows then discards), and it’s unstable under writes (if a new order is inserted at the head while a client is paginating, the client sees duplicates or skips).

Cursor pagination — the right one for anything large or write-heavy:

GET /v1/orders?limit=20&starting_after=ord_8c4e11

The server returns 20 orders after ord_8c4e11 (by created_at + id tiebreak), plus a next_cursor opaque token. The client passes the cursor back. No offset to scan, no duplicate-or-skip problem. The Stripe and GitHub REST APIs both use cursors.

The cursor itself is an opaque base64-encoded blob containing the last-seen sort key. Clients should not parse it.

Filtering, sorting, sparse fieldsets#

  • Filter with query parameters: ?status=active&customer_id=cus_18df. For multi-value filters, repeat the key (?status=active&status=trialing) or use a comma (?status=active,trialing) — pick one convention and document it.
  • Sort with a sign-prefixed field: ?sort=-created_at (descending) or ?sort=name,-created_at (multi-key). The - prefix for descending is the Stripe / GitHub convention.
  • Sparse fieldsets with ?fields=id,status,amount — return only the listed fields. Reduces payload size without GraphQL.
  • Expansion with ?expand=customer,line_items[0].product — inline related resources to avoid the N+1. Stripe pioneered this and it works.

Bulk operations#

The pain point of REST. A REST endpoint is one resource per request. If a client needs to create 1,000 orders, that’s 1,000 requests. The conventions for handling it:

  • Batch endpoint with a collective verb. POST /v1/orders:batchCreate with a body of { "orders": [...] }. The response is { "orders": [...], "errors": [...] } — partial success is the norm. Return 207 Multi-Status or 200 OK with per-item statuses.
  • Bulk via separate “job” resource. For very large bulk operations, create a job resource and poll: POST /v1/bulk_import_jobs returns { "id": "bij_...", "status": "pending" }; client polls GET /v1/bulk_import_jobs/bij_... for status. BigQuery’s load jobs are the reference.
  • Async with webhook. Same job pattern, but the server pushes a webhook when the job completes instead of the client polling.

There is no clean REST way to do truly atomic bulk operations (the kind a database BEGIN; ...; COMMIT; would give you). If you need atomicity across many resources, either model the bulk operation as its own resource or accept that REST is not the right tool for that operation.

A representative paginated GET across languages#

Listing orders with pagination, an If-None-Match conditional header, and pagination cursors.

List orders with cursor pagination — Python
import requests
cursor = None
etag = None
all_orders = []
while True:
params = {"limit": 50}
if cursor:
params["starting_after"] = cursor
headers = {"Authorization": "Bearer sk_live_..."}
if etag:
headers["If-None-Match"] = etag
resp = requests.get(
"https://api.example.com/v1/orders",
params=params,
headers=headers,
timeout=10,
)
if resp.status_code == 304:
break # nothing changed since last fetch
resp.raise_for_status()
etag = resp.headers.get("ETag")
body = resp.json()
all_orders.extend(body["data"])
if not body.get("has_more"):
break
cursor = body["next_cursor"]
print(f"fetched {len(all_orders)} orders")

Variants#

VariantMechanismWhen it fits
JSON:APIA formal spec (jsonapi.org) for response envelopes, relationships, sparse fieldsets, sortingTeams that want a prescriptive convention out of the box
HAL (Hypertext Application Language)Adds _links and _embedded to JSON responses for HATEOASThe rare API that benefits from hypermedia discovery
ODataMicrosoft’s URL-query-DSL on top of REST (?$filter=status eq 'active'&$top=10)Enterprise APIs where rich query is the main use case
REST + JSON-LDLinked-data semantics layered on JSON responsesKnowledge-graph and semantic-web style APIs
REST + ProtobufREST verbs, Protobuf bodies (Google’s transcoding gateway)Internal APIs that want gRPC’s schema with REST’s accessibility

In practice, 90% of production REST APIs are “REST-ish JSON” — Stripe-style — with none of the above. The conventions in this page are what that looks like.

Trade-offs#

What RESTful design gives you:

  • Consistency that ages well. A team that follows the conventions ships endpoints that look like the rest of the API for years.
  • Tooling for free. OpenAPI / Swagger generators, Postman collections, Stripe-style API explorers all assume these conventions.
  • A shared vocabulary with integrators. Every developer has seen GET /v1/users?limit=20 before. Familiarity is a feature.

What it costs:

  • The N+1 problem. REST is per-resource. Rich UIs that need many related resources either ship many requests or build expansion / include mechanisms that approximate GraphQL.
  • Bulk operations are awkward. No native batch verb; conventions vary.
  • No real-time. REST is request-response. Streaming or pushing means a second style (WebSocket, SSE, webhooks).
  • HTTP semantics leak. Status codes, headers, query parameters — the protocol is in the contract whether you wanted it to be or not.

Common pitfalls#

  • Verbs in paths. POST /createOrder, GET /getOrders. The verb is the HTTP method. Always.
  • Returning 200 with { "error": "..." }. Use the right status code. Clients can’t see errors that hide inside success responses.
  • Inconsistent envelopes across endpoints. One returns { "data": [...] }, another returns [...], a third returns { "results": [...] }. Pick one. Document it.
  • No request_id in responses. Integrators can’t ask you to look something up in your logs without it.
  • Offset pagination on a write-heavy resource. Use cursors.
  • PATCH that isn’t idempotent and has no If-Match ETag. Two concurrent patches silently overwrite each other.
  • Returning 201 Created without a Location header. Integrators have to parse the body to find the new ID.
  • Putting filter values in the path (/orders/active). Filters belong in the query string; the path identifies a resource.
  • No Idempotency-Key on writes. Every retryable POST needs one. This is the most common pain point integrators report.
  • Breaking changes inside /v1. Once /v1 is public, every change in it must be additive. Breaking changes go in /v2.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.