---
name: "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.*

# REST Design

## Purpose
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.

## Workflow

### 1. Name resources, not actions
- **Plural nouns, always.** `/orders`, not `/order` or `/getOrders`.
- **Hierarchy for ownership.** `/customers/{id}/orders` — an order belongs to a customer. Cap nesting at 2 levels; deeper than that, flatten (`/orders?customer_id=...`).
- **No verbs in paths.** Creating is `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.
- **Filtering, sorting, and searching live in query params**, never in the path: `GET /orders?status=paid&sort=-created_at&min_total=1000`.
- **Kebab-case in paths, one convention everywhere.** `/line-items`, not a mix of `/lineItems` and `/line_items`.
- **Idempotency-safe design:**
  - `GET`/`HEAD`/`OPTIONS` must be safe (no side effects) and `GET` must be cacheable where sensible.
  - Creation is `POST` + `Idempotency-Key` request header (client-generated UUID); the server replays the original response for a repeated key instead of creating a duplicate.
  - Full replace is `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).

### 2. Return the right status code
| 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.

### 3. Paginate deliberately: offset vs cursor
**Offset/limit** — `GET /orders?limit=20&offset=40`
- Request: `limit` (page size, cap it server-side, e.g. max 100), `offset` (rows to skip).
- Response shape (fictional `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"
  }
}
```
- Use offset when the client needs **random access** ("go to page 7") and the dataset is small and stable: admin tables, settings lists. The `total` count is expensive on big tables — omit it there.

**Cursor/keyset** — `GET /orders?limit=20&cursor=eyJpZCI6...`
- Request: `limit`, plus an opaque `cursor` returned by the previous page. First page omits `cursor`.
- Response shape (fictional `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}
}
```
- Cursor encoding: base64url of a JSON object holding the **sort key of the last row** — e.g. `{"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.
- The sort key **must be stable and unique**: always add a tie-breaker (`id`) after the visible sort column, or equal timestamps produce duplicates/skips.
- Use cursor for **feeds, infinite scroll, and any list that changes while paged** — the common case. Default to cursor unless you have a reason not to.

**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]`.
1. Client fetches `?limit=5&offset=0` → `[r10, r9, r8, r7, r6]`.
2. Two new rows insert at the head: list is now `[r12, r11, r10, r9, r8, r7, r6, ...]`.
3. Client fetches `?limit=5&offset=5` → server skips the first 5 rows (`r12, r11, r10, r9, r8`) → returns `[r7, r6, r5, r4, r3]`.
4. `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.

### 4. Version in the URL path
- Default: `/v1/orders`. Path versioning is visible, trivially testable in a browser or curl, and cache-friendly.
- Never version by incrementing resource names (`/orders2`).
- Media-type versioning (`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.
- Rules: additive changes (new fields, new endpoints) do **not** need a new version; breaking changes (renames, removed fields, changed semantics) do. Old versions get a documented sunset date, and `Sunset`/`Deprecation` response headers once one is set.

### 5. Use one error envelope everywhere
```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.
- 5xx responses keep `message` generic ("something went wrong") with the `request_id` as the only diagnostic.

## Output Contract
When asked to design or review an API, produce:
1. The resource inventory (paths + methods) following the naming rules above.
2. A status-code callout per endpoint for success and the 2–3 most likely failures.
3. The pagination choice (offset or cursor) with the reason, plus the response shape.
4. The error envelope with one example `code` per failure mode.
5. The versioning decision (`/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.

## Operating Rules
1. Cursor is the default pagination for anything user-facing or mutating; offset only for small, stable, random-access lists.
2. No 200-with-error-body, ever. No verbs in paths. No invented status codes.
3. Every 201 carries a `Location` header; every 429/503 carries `Retry-After`.
4. Error `code` values are a public contract — stable and documented.
5. Label example URLs as fictional (`api.example.com`) unless they point at a real, verified service.
6. Auth design is out of scope — see `api-auth-patterns`. GraphQL is out of scope — see `graphql-basics`.

## Design review checklist
Run this against any existing API, endpoint by endpoint:
- [ ] All collection paths are plural nouns; no verbs in any path.
- [ ] Filtering/sorting are query params, not path segments.
- [ ] `GET`s have no side effects.
- [ ] `POST` creations support `Idempotency-Key` (or the API documents why not).
- [ ] Each endpoint's success code is 200/201/204 as appropriate; 201s include `Location`.
- [ ] No 200-with-error-body anywhere; failures use 4xx/5xx.
- [ ] 400 vs 422 split is consistent (malformed vs semantically invalid).
- [ ] 401 (who are you) vs 403 (not allowed) vs 404 (not found / hiding existence) are used correctly.
- [ ] 409 used for state conflicts (duplicates, stale versions), not 400.
- [ ] 429/503 include `Retry-After`.
- [ ] List endpoints paginate; the choice (offset/cursor) matches the access pattern; sort keys are stable with an id tie-breaker.
- [ ] Every error response matches the envelope (`code`, `message`, `details`, `request_id`); 5xx messages are generic.
- [ ] Version is in the path (`/v1/`); breaking-change policy is documented.
