REST API design, resources, verbs, and the decisions that actually matter
What REST is, and isn’t
REST (Representational State Transfer) is a style Roy Fielding defined in his 2000 PhD thesis describing HTTP’s core constraints. A true REST API is stateless, cacheable, layered, uniform in interface, and exposes hypermedia (HATEOAS) for driving the client state machine.
Approximately 0% of production APIs pass all those tests. What people ship is usually “REST-ish”, HTTP + JSON + verbs-on-resources. That’s fine. The value of the REST style is the direction, resources, uniform interface, statelessness, not perfect adherence to Fielding’s thesis.
This post is about REST as practiced, with the bits of theory that still pay off.
The mental model
Resources are nouns. Verbs act on nouns. Status codes describe the result.
GET /visits list visitsPOST /visits create a visitGET /visits/42 retrieve visit 42PATCH /visits/42 partially update visit 42PUT /visits/42 replace visit 42 entirelyDELETE /visits/42 delete visit 42Each row has a predictable effect. Clients can read URLs and guess what happens; engineers can read the route table and understand the system.
Resource modeling
The hardest design decision is usually: what are the resources?
Three tests for a good resource:
- It has a stable identity. A URL that means the same thing tomorrow.
- It has a clear lifecycle. Creation, updates, deletion.
- Its verbs (CRUD) map reasonably to user operations.
A User is a resource. A SearchResult usually isn’t. An Order is; SubmitOrder isn’t, it’s an action on an Order.
Nested resources
Nesting models ownership: /patients/7/visits reads as “visits belonging to patient 7.” Good for one level. Beyond that, flatten:
/patients/7/visits/42/notes/3/reactions ← too deep, fragile/notes/3/reactions ← flat, with note_id in the noteTwo-level nesting is the sweet spot. Deeper, and clients struggle to build the URL.
Actions that aren’t CRUD
Some operations don’t fit CRUD: “approve,” “cancel,” “send.” Two patterns:
POST /orders/42/approve ← action as a sub-resourcePOST /orders/42 { op: approve } ← action in the bodyThe first is cleaner for REST tooling (OpenAPI understands it; middleware and caches can reason about it). The second is closer to JSON-RPC. Pick one and apply it consistently.
Avoid mixing: having some actions as verbs (/cancel) and some as body fields makes the API surface feel arbitrary.
The verbs, semantic details
| Verb | Semantics | Idempotent | Safe | Body |
|---|---|---|---|---|
| GET | Read | Yes | Yes | No |
| HEAD | Read headers only | Yes | Yes | No |
| OPTIONS | Discover capabilities | Yes | Yes | Usually no |
| POST | Create / action | No | No | Yes |
| PUT | Replace | Yes | No | Yes |
| PATCH | Partial update | No* | No | Yes |
| DELETE | Remove | Yes | No | Usually no |
- Safe = no observable state change. GET and HEAD.
- Idempotent = repeating the request yields the same result as one request. GET, PUT, DELETE. POST is not idempotent; PATCH is sometimes idempotent, sometimes not.
Two practical consequences:
- Clients and CDNs can retry idempotent requests safely. That’s a reason to structure mutations as PUT when you can.
- Don’t change state in GET. This is the single most broken thing, pre-fetching crawlers, HEAD probes, and caches will replay GETs arbitrarily.
Status codes
The minimum set you must know:
- 200 OK, success with a body.
- 201 Created, POST success with a new resource; include
Locationheader. - 204 No Content, success with no body (DELETE, sometimes PATCH).
- 301 / 302 / 307 / 308, redirects (308 permanent, 307 temporary, strict method preservation; 301/302 have legacy method-change behavior).
- 400 Bad Request, malformed request (bad JSON, missing required field).
- 401 Unauthorized, no or bad credentials. (Really means “unauthenticated.”)
- 403 Forbidden, authenticated but not allowed.
- 404 Not Found, resource doesn’t exist (or you shouldn’t reveal it).
- 405 Method Not Allowed, wrong verb on a valid URL.
- 409 Conflict, state conflict (illegal state transition, version mismatch, duplicate).
- 410 Gone, existed, now permanently removed.
- 415 Unsupported Media Type, wrong content-type.
- 422 Unprocessable Entity, syntactically valid but semantically wrong (DRF’s default for validation errors).
- 429 Too Many Requests, rate limit. Include
Retry-After. - 500 Internal Server Error, generic blow-up. You should rarely see this in logs without a corresponding alert.
- 503 Service Unavailable, temporary outage; include
Retry-Afterif you can predict it.
Pick codes deliberately. Returning 200 with {"error": "..."} breaks every HTTP tool (caching, monitoring, tracing, retries).
Pagination
Three patterns, each with trade-offs:
Offset / limit
GET /visits?offset=100&limit=50Easiest to implement. Bad for large datasets (OFFSET scales poorly) and wrong if rows are inserted between pages.
Page / size
GET /visits?page=3&size=50Same problems as offset under the hood. Wraps them in a slightly friendlier URL.
Cursor-based
GET /visits?cursor=eyJsYXN0X2lkIjoxMjM0fQ==&limit=50→ { items: [...], next_cursor: "eyJsYXN0X2lkIjoxMjg0fQ==" }The server returns an opaque cursor encoding where to continue. Stable across inserts, fast on databases. Clients can’t jump to page N, which is usually fine.
For any list that might exceed a few hundred items, default to cursor-based pagination. It’s the pattern GitHub, Stripe, and Twitter all converged on.
Filtering, sorting, field selection
GET /visits?status=assigned&clinician_id=17&sort=-window_start&fields=id,status,patientKeep the query string conventional:
- Filter by exact match with the field name.
- Sort with a
sort=param; prefix with-for descending. - Field selection with
fields=(list of fields to include). Useful for bandwidth-sensitive clients.
Avoid inventing DSLs in query strings (?q=status:assigned AND clinician_id:17). They’re fun to design, painful to use.
Versioning
Four options, in descending order of popularity:
1. URL path, /v1/visits
Simplest, most visible, easiest to route. Every major public API does this.
2. Accept header, Accept: application/vnd.acme.v1+json
Keeps URLs clean; harder to debug with curl. Purist-favored.
3. Custom header, X-API-Version: 2026-01-01
Like option 2 but explicit.
4. No versioning, backwards-compatible evolution
Stripe’s approach, pin a version at signup (Stripe-Version: 2026-01-01), add fields freely (clients ignore unknown), never remove or rename.
Pick option 1 for most B2B/internal APIs. Pick option 4 if your API evolves fast and you have the discipline to never break clients.
Error shapes
Pick one; apply everywhere:
{ "error": { "type": "validation_error", "message": "email is required", "field": "email", "code": "missing_required_field" }}Minimums:
- A machine-readable
typeorcode. - A human-readable
messagefor debugging. - When the error is field-specific, name the field.
- A stable request ID so you can find logs.
RFC 7807, Problem Details for HTTP APIs is the canonical format if you want a standard:
{ "type": "https://acme.com/probs/validation", "title": "Validation failed", "status": 422, "detail": "email is required", "instance": "/api/v1/users"}Authentication and authorization, what REST says
REST doesn’t dictate how to authenticate, but idiomatic choices:
- Bearer tokens in
Authorizationheader.Authorization: Bearer <token>. - API keys for server-to-server. Rotatable, scoped, logged.
- Session cookies for first-party web UIs.
HttpOnly,Secure,SameSite=Lax/Strict. - OAuth 2 / OIDC for federated auth.
Avoid auth via query parameters. Query strings end up in access logs and browser history.
See the companion sessions, JWTs, and cookies post for the security tradeoffs.
Rate limiting
API responses should include rate-limit headers so clients can self-throttle:
X-RateLimit-Limit: 100X-RateLimit-Remaining: 74X-RateLimit-Reset: 1714065600Retry-After: 30Actual rate limiting is its own topic, see the throttling and rate-limiting post.
Idempotency keys
For POST operations that create resources, clients should be able to retry safely. The pattern:
POST /chargesIdempotency-Key: f47ac10b-58cc-4372-a567-0e02b2c3d479{ ... }Server stores a hash of the key + request body. Re-sends with the same key return the original response, not a duplicate.
Stripe popularized this; most new APIs with “actions that must not be duplicated” (payments, bookings, bulk imports) include it.
HATEOAS, the part no one uses
Fielding’s purist REST requires hypermedia controls, responses contain links to next actions:
{ "id": 42, "status": "scheduled", "_links": { "self": { "href": "/visits/42" }, "cancel": { "href": "/visits/42/cancel", "method": "POST" }, "assign": { "href": "/visits/42/assign", "method": "POST" } }}In principle, the client could walk only links from a single entry URL, never hard-coding paths. In practice, ~no client does this. HATEOAS comes and goes in fashion; most 2026 APIs ignore it, and their clients work fine.
OpenAPI, the spec that won
Write your API spec in OpenAPI (Swagger). Generate clients, validate requests, render documentation.
openapi: 3.1.0info: title: Home Health API version: '1.0.0'paths: /visits/{id}/assign: post: summary: Assign a clinician to a visit parameters: - in: path name: id schema: { type: integer } required: true requestBody: required: true content: application/json: schema: type: object properties: clinician_id: { type: integer } responses: '200': description: Assigned '409': description: Illegal state transitionSpec-first is the modern default: write the OpenAPI doc, generate types on both sides, implement to match. Tools:
- drf-spectacular, Django REST Framework’s generator.
- FastAPI, spec is generated from code.
- Stoplight / Redocly, spec-first design tools.
- openapi-typescript, generate TS types from a spec.
Common mistakes
- GET that mutates. Crawlers will hit it. Always.
- 201 without
Locationheader. Clients have to guess the URL of the new resource. - Inconsistent error shapes. Two endpoints return errors differently; clients need two parsers.
- Mixing verbs and nouns.
/getUsersis not REST./userswithGETis. - Overloading 500. Validation failure returns 500 with a leaked stack trace. Use 4xx for client errors, 5xx only for your server’s problems.
- Nested resources 4 levels deep. Flatten.
- No pagination. “It’s fine at launch” becomes a 30-second query in a year.
- Breaking changes without a version. Even a renamed field can break a client. Bump the version.
- Timestamps without timezone. Always use ISO 8601 with
Zor offset. - Inconsistent casing.
snake_caseorcamelCase, pick one in JSON and stick to it. - Chatty clients. Listing requires 50 calls. Support batch or field selection.
The small set of decisions that matter
If you spend thought on these four, the rest of the API design falls into place:
- Error shape. Pick one, apply everywhere. Machine-readable code + human message + field.
- Pagination. Default to cursor-based; document the cursor opacity; include a sane max limit.
- Versioning. Decide before the first public call. Migration is painful; greenfield is free.
- Auth model. Bearer token, API key, session cookie? Mix of these for which user types?
Four decisions. Every other design question (casing, verbs, status codes) has a conventional answer that works; these four don’t.
References
- Roy Fielding, Architectural Styles and the Design of Network-based Software Architectures, the thesis
- Microsoft, REST API design guidelines, the most concrete public style guide
- Google, API Design Guide, emphasizes resource design
- Stripe API reference, the de facto best-in-class public API
- GitHub REST API, second canonical reference
- JSON:API spec, for teams that want stronger conventions
- RFC 7807, Problem Details, standard error shape
- RFC 9110, HTTP Semantics, authoritative modern HTTP reference