RESTful API Design in Practice
Path conventions, status codes that fit, payload shape, pagination, filtering, the bulk-operations friction every REST API hits.
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:
- Path naming (resource-oriented, plural nouns, no verbs).
- HTTP verb semantics (
GET/POST/PUT/PATCH/DELETE). - Status codes that actually fit the outcome.
- Payload shape (request and response envelopes).
- Pagination (offset vs cursor, headers vs body).
- Filtering, sorting, and sparse fieldsets.
- Bulk operations (the place REST loses on throughput).
- 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 /orderscreates an order;GET /orders/{id}reads one. NeverPOST /createOrder. - Nested resources for true ownership.
/users/{id}/ordersis fine if orders are owned by a user./orders?user_id=Xis 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/billingAddressesor/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/DELETEsemantics, a collective verb after a colon is the convention:POST /orders/{id}:cancel,POST /orders:batchCreate. Google’s API guidelines codified this.
# GoodGET /v1/ordersPOST /v1/ordersGET /v1/orders/ord_a3f9c2PATCH /v1/orders/ord_a3f9c2DELETE /v1/orders/ord_a3f9c2POST /v1/orders/ord_a3f9c2:cancelGET /v1/users/usr_7b1d/orders
# BadGET /v1/getOrdersPOST /v1/createOrderPOST /v1/order/cancel?id=ord_a3f9c2GET /v1/orderListHTTP verbs#
The verb taxonomy is the same as in the REST — The Architectural Style page. The discipline in RESTful design:
GETis safe and idempotent. No side effects on resource state. (Logging and view counters don’t count.)POSTcreates a resource the server names (returnsLocation: /orders/{id}). Non-idempotent unless you use anIdempotency-Key.PUTreplaces an entire resource the client names (PUT /orders/{id}with a complete representation). Idempotent.PATCHpartially 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 anIf-MatchETag for safe concurrent edits.DELETEremoves. Idempotent —DELETEof an already-deleted resource returns the same status as the original delete.
Status codes that fit#
The right code for the actual outcome:
| Outcome | Status | Body |
|---|---|---|
| Read succeeded | 200 OK | Resource |
Resource created (POST) | 201 Created | Resource + Location header |
| Async accepted, will process later | 202 Accepted | { "task_id": "..." } |
| Update / delete succeeded, no body needed | 204 No Content | empty |
| Cached representation still valid | 304 Not Modified | empty (conditional GET) |
| Malformed JSON / missing required field | 400 Bad Request | error envelope |
| Missing or invalid credentials | 401 Unauthorized | error envelope |
| Authenticated but not allowed | 403 Forbidden | error envelope |
| Resource doesn’t exist | 404 Not Found | error envelope |
| State conflict (already-confirmed order being confirmed again) | 409 Conflict | error envelope |
| Payload is well-formed but semantically invalid | 422 Unprocessable Entity | error envelope with field-level details |
| Rate limit exceeded | 429 Too Many Requests | error envelope + Retry-After |
| Server bug | 500 Internal Server Error | error envelope (no stack trace) |
| Upstream timeout / unavailable | 503 Service Unavailable | error 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:
{ "id": "ord_a3f9c2", "object": "order", "status": "confirmed", "amount": { "currency": "USD", "value_minor": 4999 }, "created_at": "2026-05-30T08:14:23Z"}For a list:
{ "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": { "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=20Returns 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_8c4e11The 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:batchCreatewith a body of{ "orders": [...] }. The response is{ "orders": [...], "errors": [...] }— partial success is the norm. Return207 Multi-Statusor200 OKwith per-item statuses. - Bulk via separate “job” resource. For very large bulk operations, create a job resource and poll:
POST /v1/bulk_import_jobsreturns{ "id": "bij_...", "status": "pending" }; client pollsGET /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.
import requests
cursor = Noneetag = Noneall_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")package main
import ( "encoding/json" "fmt" "net/http" "net/url")
type listResp struct { Data []json.RawMessage `json:"data"` HasMore bool `json:"has_more"` NextCursor string `json:"next_cursor"`}
func main() { cursor, etag := "", "" var all []json.RawMessage
for { q := url.Values{} q.Set("limit", "50") if cursor != "" { q.Set("starting_after", cursor) }
req, _ := http.NewRequest("GET", "https://api.example.com/v1/orders?"+q.Encode(), nil) req.Header.Set("Authorization", "Bearer sk_live_...") if etag != "" { req.Header.Set("If-None-Match", etag) }
resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified { break } etag = resp.Header.Get("ETag")
var body listResp json.NewDecoder(resp.Body).Decode(&body) all = append(all, body.Data...)
if !body.HasMore { break } cursor = body.NextCursor }
fmt.Printf("fetched %d orders\n", len(all))}let cursor = null;let etag = null;const all = [];
while (true) { const params = new URLSearchParams({ limit: "50" }); if (cursor) params.set("starting_after", cursor);
const headers = { Authorization: "Bearer sk_live_..." }; if (etag) headers["If-None-Match"] = etag;
const resp = await fetch(`https://api.example.com/v1/orders?${params}`, { headers });
if (resp.status === 304) break; if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
etag = resp.headers.get("ETag"); const body = await resp.json(); all.push(...body.data);
if (!body.has_more) break; cursor = body.next_cursor;}
console.log(`fetched ${all.length} orders`);Variants#
| Variant | Mechanism | When it fits |
|---|---|---|
| JSON:API | A formal spec (jsonapi.org) for response envelopes, relationships, sparse fieldsets, sorting | Teams that want a prescriptive convention out of the box |
| HAL (Hypertext Application Language) | Adds _links and _embedded to JSON responses for HATEOAS | The rare API that benefits from hypermedia discovery |
| OData | Microsoft’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-LD | Linked-data semantics layered on JSON responses | Knowledge-graph and semantic-web style APIs |
| REST + Protobuf | REST 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=20before. 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 /
includemechanisms 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
200with{ "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_idin 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-MatchETag. Two concurrent patches silently overwrite each other. - Returning
201 Createdwithout aLocationheader. 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-Keyon writes. Every retryablePOSTneeds one. This is the most common pain point integrators report. - Breaking changes inside
/v1. Once/v1is public, every change in it must be additive. Breaking changes go in/v2.
Related building blocks#
- REST — The Architectural Style — REST as an architectural style.
- HTTP — The Foundational Protocol for APIs — the protocol underneath.
- GraphQL — A Query Language for APIs — the alternative for client-shaped responses.
- gRPC — Protobuf over HTTP/2 — the alternative for internal high-throughput.
- The Role of Idempotency in API Design — the missing piece every REST POST needs.
- API Versioning — how
/v1doesn’t become/v2for as long as possible. - Rate Limiting —
429andRetry-After, in depth.