| name | rest-api-design |
| description | REST API design best practices — URI naming, HTTP verbs/status codes, pagination, filtering, error envelopes, versioning, security. Based on Octo's API design guide with modern updates.
TRIGGER when: designing REST API, designing endpoint, API design review, URL design, resource naming, HTTP verb choice, status code choice, pagination strategy, cursor vs page pagination, error format, response envelope, API versioning, REST conventions, RESTful, "/users vs /user", PATCH vs PUT, 201 vs 200, 400 vs 422, idempotency key, rate limiting headers, expand/include parameter, HATEOAS, OAuth2, reviewing routes/controllers/handlers.
DO NOT USE when: GraphQL, gRPC, WebSocket, or RPC-style APIs; pure backend logic unrelated to HTTP surface; OpenAPI/Swagger tooling questions only (use generic tools).
|
REST API Design
API designed for the developer-client. KISS, standard vocabulary, predictable shapes — expose what clients need, not the internal data model.
Workflow
- Identify resources — nouns, plural, ≤2 levels of nesting
- Map operations to HTTP verbs + status codes (table below)
- Define pagination / filter / error envelope / auth upfront, not retrofitted
- Document with copy-paste-ready
curl examples per endpoint
URI Rules
| Rule | Do | Don't |
|---|
| Nouns, plural everywhere | GET /v1/users/007 | getUser(007), /v1/user/007 |
| Versioning in path | /v1/orders | header-only or no version |
| Nesting depth | max 2: /users/42/orders | /users/42/orders/7/items/3 |
| Case consistency | pick snake_case or spinal-case, stay consistent | mixed per endpoint |
| Action endpoints | rare, scoped: POST /carts/7/commit | POST /createCart, GET /getUser |
Domain segregation: api.example.com, oauth2.example.com, developers.example.com, plus .sandbox. mirror.
HTTP Verbs ↔ CRUD
| Verb | On collection | On instance | Idempotent | Notes |
|---|
GET | list | read | yes | safe |
POST | create → 201 + Location: | — | no | also for non-resource actions |
PUT | — | full replace | yes | unspecified fields cleared |
PATCH | — | partial update | no | preferred for partial |
DELETE | — | delete → 204 | yes | |
POST /v1/clients/007/orders
< 201 Created
< Location: https://api.example.com/v1/clients/007/orders/1234
Status Codes
Success — 200 OK · 201 Created (+Location:) · 202 Accepted (async) · 204 No Content (DELETE) · 206 Partial Content (paginated)
Client error — 400 Bad Request · 401 Unauthorized (no/invalid creds) · 403 Forbidden (authenticated but not allowed) · 404 Not Found · 405 Method Not Allowed · 406 Not Acceptable (content negotiation) · 409 Conflict (state) · 422 Unprocessable Entity (validation) · 429 Too Many Requests
Server error — 500 Internal · 502 Bad Gateway · 503 Service Unavailable
400 vs 422 — 422 = format/schema/type wrong (Pydantic/validator failure). 400 = format valid but operation illegal (cancel an already-shipped order, transfer more than balance). Different signals to the client; don't conflate.
Response Envelope (optional)
Two stylistic choices — pick one project-wide:
Bare (HTTP-native, Octo): return the resource directly. Status code + headers carry meta. 200 OK body = {"id": 7, ...}. Errors use a separate envelope (below).
Wrapped (Stripe/GitHub-flavored): every response — success or error — wears the same shell:
{ "success": true, "data": {...}, "pagination": {...} }
{ "success": false, "error_code": "NOT_FOUND", "message": "...", "details": null }
Tradeoff: wrapped is friendlier to one generic client handler; bare is leaner and reuses HTTP semantics. Don't mix. Consistency across the whole API is the point.
Error Envelope
Pick one shape, apply across the whole API.
OAuth2-style (Octo recommendation, reuses OAuth2 spec):
{
"error": "invalid_request",
"error_description": "There is no 'payed' property on users.",
"error_uri": "https://developers.example.com/errors/invalid_request"
}
RFC 7807 Problem Details (modern alternative, Content-Type: application/problem+json):
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"status": 403,
"detail": "Your balance is 30, but charge was 50.",
"instance": "/account/12345/msgs/abc"
}
Pagination
HTTP Range semantics (Octo, reuses HTTP):
GET /v1/restaurants?range=0-24
< 206 Partial Content
< Content-Range: 0-24/48
< Accept-Range: restaurant 50
< Link: <https://api.example.com/v1/restaurants?range=25-49>; rel="next",
<https://api.example.com/v1/restaurants?range=0-24>; rel="first",
<https://api.example.com/v1/restaurants?range=25-47>; rel="last"
400 Bad Request if range exceeds Accept-Range max.
- Navigation links use RFC 5988
Link: header.
Cursor pagination (Stripe-style, prefer for large/mutable/streaming sets):
GET /v1/events?limit=25&after=evt_abc123
< 200 OK
< {"data":[...], "has_more": true, "next_cursor": "evt_xyz789"}
- Opaque cursor: base64-encode the position key (e.g.
{id, created_at}). Clients pass it back blindly — you can change the encoding later without breaking them.
limit + 1 trick: fetch limit + 1 rows; if you got the extra, set has_more: true and slice it off. Avoids a separate COUNT(*) on the full table.
- Tradeoff: avoids skip-cost and shifting-offset duplicates; loses random-page access ("go to page 7").
When: page-based for admin panels / small datasets / "go to page N" UIs. Cursor for feeds, timelines, infinite scroll, high-volume, mutable.
Filter, Sort, Search
GET /v1/restaurants?type=thai,japanese&rating=4,5 # filter (CSV multi-value)
GET /v1/restaurants?sort=rating,name&desc=rating # Octo: asc default; mark desc fields
GET /v1/restaurants?sort=-rating,name # Stripe/GitHub: -prefix = desc (more common)
GET /v1/orders?min_total=10&max_total=500 # range filters: min_/max_ prefix
GET /v1/orders?created_after=2026-01-01 # date filters: _after/_before suffix
GET /v1/restaurants/search?name=la*&zipcode=75* # scoped search (wildcards)
GET /v1/search?q=running+paid # global search
Whitelist sortable + filterable fields server-side. Free-form ?sort=<anything> exposes you to surprise ORDER BY on unindexed columns and JOIN explosions. Same for ?filter=.
Expand / Include (related resources)
Let clients pull nested data in one call instead of N round-trips:
GET /v1/orders/123 # order only
GET /v1/orders/123?expand=customer,items # order + nested customer + items
- Whitelist allowed expansions (set intersection). Blocks random JOIN chains and accidental data exposure.
- Eager-load on the server (
JOIN / selectinload); never N+1 inside a loop.
- Pattern from Stripe, Shopify. Clients control payload size — mobile gets the lean version, dashboard the full version, same endpoint.
Partial Responses
Google syntax — let clients shrink payloads:
GET /v1/clients/007?fields=firstname,name,address(street)
HATEOAS
Make resources discoverable. Pick one approach project-wide.
RFC 5988 Link: header (preferred — no body coupling):
GET /v1/clients/007
< 200 OK
< {"id":"007", "firstname":"James"}
< Link: <https://api.example.com/v1/addresses/42>; rel="addresses"; method="GET",
<https://api.example.com/v1/orders/1234>; rel="orders"; method="GET"
Embedded JSON (PayPal-style):
"_links": [
{"href": "...", "rel": "self", "method": "GET"},
{"href": "...", "rel": "approval_url", "method": "REDIRECT"}
]
Security
- OAuth2 + HTTPS everywhere — no OAuth1, no plain HTTP for auth or API.
- Bearer JWT for stateless services; OIDC when identity claims needed.
- Scopes per endpoint — least privilege; document required scope in API reference.
- Validate tokens; rotate signing keys; never log tokens.
Idempotency
For non-idempotent ops (POST payments, transfers, order creation), accept an Idempotency-Key request header. Cache the response keyed by it; replay returns the same body + X-Idempotent-Replayed: true.
POST /v1/payments
Idempotency-Key: 8b2f7e6c-...
{ "amount": 4200, "currency": "EUR" }
→ first call: 201 Created + Location: ...
→ replay: 201 Created + X-Idempotent-Replayed: true (same body)
- TTL ~24h is the Stripe default.
- Apply only to state-changing methods:
POST, PUT, PATCH. GET/DELETE are already idempotent.
- Cache 2xx and 4xx (not 5xx — let the client retry transient server errors).
Rate Limiting
Return limit state in headers on every response; reject with 429 + Retry-After when exceeded.
< X-RateLimit-Limit: 1000
< X-RateLimit-Remaining: 42
< X-RateLimit-Reset: 1735689600 # epoch seconds
429 Too Many Requests
Retry-After: 30
{ "error": "rate_limited", "error_description": "Try again in 30s." }
Content Negotiation
GET /v1/users
Accept: application/json
< 200 OK
< Content-Type: application/json
- Honor
Accept: order; respond 406 Not Acceptable if no supported format matches.
- JSON is the default. XML only if a client explicitly requires it (no longer mandatory).
Review / Pre-Launch Checklist
Run before any endpoint ships. Tier = severity if violated.
| Tier | Check |
|---|
| Blocker | Missing/broken auth; plain HTTP for auth or API; wrong verb (PUT for partial — clears unspecified fields); 2xx returned on error; no input validation; no Idempotency-Key on state-changing side-effect endpoints (payments, orders, sends) |
| Blocker | Status codes correct: 201 create (+Location:), 204 delete, 409 conflict, 422 validation, 400 illegal-but-valid op |
| Concern | Inconsistent case; ad-hoc/HTML error shape that varies per endpoint; missing pagination headers; list endpoints not paginated from day one (adding later = breaking); no version in path; nesting > 2 levels; missing rate-limit headers + 429/Retry-After; free-form sort/filter without server-side whitelist |
| Concern | Response shape not consistent project-wide (bare or wrapped — never mixed); deprecation policy undocumented (≤2 versions parallel, ≥6 month sunset) |
| Concern | Errors lack machine-readable error_code + human message; validation errors lack field-level detail |
| Nit | Singular noun for instance (/user/7 beside /users — pick plural); missing Location: on 201; no X-RateLimit-* exposed; missing curl example / OpenAPI 3.x spec in docs |
| Smell | Frequent verbs in URLs (/getUsers, /createOrder) ⇒ you have RPC, not REST — revisit; ?page=2&size=20 without standardized headers is OK but document; JSONP is deprecated — use CORS |
References
Benchmark
Scenario: .benchmarks/scenarios/rest-api-001-status-code.md · Run: 2026-06-14
| Model | Without | With | Delta |
|---|
| claude-opus-4-8 | 40% | 100% | +60% |
| claude-sonnet-4-6 | 40% | 60% | +20% |
| claude-haiku-4-5 | 40% | 60% | +20% |
PASS. Flips the core 400-vs-422 call on all 3 models (every baseline wrongly chose 422). Weaker models under-apply secondary idempotency/409 guidance — candidate for higher salience. Gate per skill-optimizer/release-gates.md.