Design REST APIs: resource naming conventions, which HTTP status code to use when, offset vs cursor pagination, URL versioning, and error-envelope conventions. Triggers on 'design an API', 'REST endpoint', 'which status code', 'pagination', 'offset vs cursor', 'API versioning', 'API design review'.
./install.sh rest-designname: "rest_design" description: "Design REST APIs: resource naming conventions, which HTTP status code to use when, offset vs cursor pagination, URL versioning, and error-envelope conventions. Triggers on 'design an API', 'REST endpoint', 'which status code', 'pagination', 'offset vs cursor', 'API versioning', 'API design review'." metadata: { "includeInPrompt": true }
differs from graphql-basics because this is REST-specific: resource naming, HTTP status codes, offset/cursor pagination; differs from api-auth-patterns because auth schemes are out of scope here.
Give agents a concrete, opinionated recipe for designing REST APIs: how to name resources, which status code to return in each situation, how to paginate, how to version, and what error responses look like. Use it when drafting a new API or reviewing an existing one.
/orders, not /order or /getOrders./customers/{id}/orders — an order belongs to a customer. Cap nesting at 2 levels; deeper than that, flatten (/orders?customer_id=...).POST /orders; cancelling is a sub-resource POST /orders/{id}/cancellations, or, for verbs that resist nouning, a colon-suffixed custom method POST /orders/{id}:cancel (Google API convention). Pick one style per API and never mix.GET /orders?status=paid&sort=-created_at&min_total=1000./line-items, not a mix of /lineItems and /line_items.GET/HEAD/OPTIONS must be safe (no side effects) and GET must be cacheable where sensible.POST + Idempotency-Key request header (client-generated UUID); the server replays the original response for a repeated key instead of creating a duplicate.PUT with a client-chosen id (PUT /webhooks/wh_abc); partial update is PATCH with a JSON merge-patch body.DELETE is naturally idempotent — deleting an already-deleted resource returns 204 (or 404; pick one and document it).| Code | Meaning | Use when | |------|---------|----------| | 200 | OK | Successful GET, or POST/PATCH that returns the updated representation. | | 201 | Created | A POST created a resource. Always include a Location header pointing at the new resource. | | 204 | No Content | Successful DELETE, or a PUT/POST with nothing to return. No body. | | 400 | Bad Request | Malformed request: bad JSON, bad query-param types, missing required field. | | 401 | Unauthorized | Missing or invalid credentials. The client can retry with auth. | | 403 | Forbidden | Authenticated, but not allowed (wrong role, not the owner). Do not leak whether the resource exists — prefer 404 for existence-hiding. | | 404 | Not Found | No such resource (or you are hiding existence from a 403). | | 409 | Conflict | State conflict: duplicate unique key, version/etag mismatch, transition not allowed (e.g. refunding an already-refunded order). | | 422 | Unprocessable Entity | Well-formed request that fails semantic validation ("quantity must be >= 1"). Keeps validation distinct from malformed syntax (400). | | 429 | Too Many Requests | Rate limited. Always include a Retry-After header (seconds). | | 500 | Internal Server Error | Unexpected server failure. Generic message only — never leak stack traces. | | 503 | Service Unavailable | Temporarily down or overloaded. Include Retry-After when you know the recovery window. |
Hard rule: never return 200 with an error body. Errors ride on 4xx/5xx. Clients, caches, and load balancers all key off the status line; a 200-that-failed breaks every one of them.
Offset/limit — GET /orders?limit=20&offset=40
limit (page size, cap it server-side, e.g. max 100), offset (rows to skip).api.example.com):json
{
"data": [
{"id": "ord_101", "total_cents": 4999, "created_at": "2026-09-20T14:00:00Z"},
{"id": "ord_102", "total_cents": 1299, "created_at": "2026-09-20T14:01:12Z"}
],
"meta": {"limit": 2, "offset": 40, "total": 1287},
"links": {
"self": "https://api.example.com/v1/orders?limit=2&offset=40",
"next": "https://api.example.com/v1/orders?limit=2&offset=42",
"prev": "https://api.example.com/v1/orders?limit=2&offset=38"
}
}
total count is expensive on big tables — omit it there.Cursor/keyset — GET /orders?limit=20&cursor=eyJpZCI6...
limit, plus an opaque cursor returned by the previous page. First page omits cursor.api.example.com):json
{
"data": [
{"id": "ord_842", "total_cents": 2199, "created_at": "2026-09-21T09:12:03Z"},
{"id": "ord_841", "total_cents": 899, "created_at": "2026-09-21T09:10:44Z"}
],
"page": {"next_cursor": "eyJpZCI6Im9yZF84NDEiLCJ0cyI6IjIwMjYtMDktMjFUMDk6MTA6NDRaIn0", "has_more": true}
}
{"id":"ord_841","ts":"2026-09-21T09:10:44Z"}. The server decodes it and queries WHERE (created_at, id) < (ts, id) ORDER BY created_at DESC, id DESC LIMIT 20. Clients treat the cursor as opaque; never let clients construct one.id) after the visible sort column, or equal timestamps produce duplicates/skips.Why offset breaks under concurrent inserts (worked example): Rows newest-first, page size 5. At t0: [r10, r9, r8, r7, r6, r5, r4, r3, r2, r1].
?limit=5&offset=0 → [r10, r9, r8, r7, r6].[r12, r11, r10, r9, r8, r7, r6, ...].?limit=5&offset=5 → server skips the first 5 rows (r12, r11, r10, r9, r8) → returns [r7, r6, r5, r4, r3].r7 and r6 were already on page 1 — duplicates; deletes between pages cause the mirror problem, skips. With cursor pagination, step 3 would be "rows strictly older than r6's sort key", which inserts at the head cannot disturb.Live sanity check of your query-string shape (httpbin echoes the request back; verified 2026-09-22, ~1.3s through the default env proxy — do not use --noproxy '*'):
bash
curl -s 'https://httpbin.org/get?limit=2&offset=4&sort=-created_at' | python3 -m json.tool
httpbin is not a paginated API — it only proves your params are well-formed before you point at a real backend.
/v1/orders. Path versioning is visible, trivially testable in a browser or curl, and cache-friendly./orders2).Accept: application/vnd.shop.v2+json) is the alternative for cases where the URL must stay frozen; note it and move on — it is harder to debug and most teams should not start there.Sunset/Deprecation response headers once one is set.json
{
"error": {
"code": "validation_failed",
"message": "quantity must be a positive integer",
"details": [{"field": "quantity", "issue": "expected integer >= 1, got 0"}],
"request_id": "req_9f3a2c1e"
}
}
code: stable, machine-readable snake_case clients can switch on. Never change a code's meaning; add a new code instead.message: human-readable, one sentence, no stack traces, no PII.details: array of field-level problems for 400/422; empty or omitted otherwise.request_id: unique per request, echoed in logs — this is how a client report turns into a server-side trace.message generic ("something went wrong") with the request_id as the only diagnostic.When asked to design or review an API, produce:
code per failure mode./v1/ default) and what counts as breaking.When asked only to review, produce the checklist below with pass/fail per item and the offending endpoint.
Location header; every 429/503 carries Retry-After.code values are a public contract — stable and documented.api.example.com) unless they point at a real, verified service.api-auth-patterns. GraphQL is out of scope — see graphql-basics.Run this against any existing API, endpoint by endpoint:
GETs have no side effects.POST creations support Idempotency-Key (or the API documents why not).Location.Retry-After.code, message, details, request_id); 5xx messages are generic./v1/); breaking-change policy is documented.publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.