REST API Design skill for Claude Code
Predictable endpoints, machine-readable errors, safe retries. Claude designs endpoints spec-first instead of improvising handlers. It matches your existing conventions, picks exact status codes, returns RFC 9457 problem documents, paginates with stable cursors, adds Idempotency-Key and ETag support where retries or concurrent edits happen, and runs oasdiff so a renamed field never ships as a silent breaking change.
- name
rest-api-design - size194 lines · ≈2,250 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and reviews HTTP/REST APIs contract-first - resource modeling, URL and method semantics, status codes, cursor pagination, filtering, RFC 9457 problem-details errors, idempotency keys, versioning and deprecation, and OpenAPI 3.1 specs linted and diffed in CI. Use when adding endpoints, writing or reviewing an OpenAPI file, choosing status codes or error formats, designing pagination, or planning a breaking change to a public API. Try asking
- Design the endpoints for orders and refunds and write the OpenAPI spec
- Review this OpenAPI file for inconsistent status codes and error shapes
- We need to rename a field in our public API. Plan it without breaking clients
Use it when
- Adding or changing HTTP endpoints on a JSON API
- Writing, linting or reviewing an OpenAPI 3.x document
- Choosing between status codes, error formats or pagination styles
- Planning a versioning or deprecation path for a public API
Skip it when
- The API is GraphQL — use graphql-schema instead
- The service talks protobuf over HTTP/2 — use grpc-services instead
- You need a security audit of auth and object access — use api-security
How to check the result
The spec lints with zero errors, oasdiff reports no unintended breaking changes against the last release, and contract tests validate live responses against the spec.
The common mistake it prevents
Returning 200 with an error flag in the body, which hides failures from monitoring, caches and every HTTP client library.
A good API is predictable: a client who has used two endpoints can guess the third. Done well means the OpenAPI spec is the source of truth, every error is machine-readable, every list is paginated, every retry is safe, and no change breaks an existing client without a versioned, announced path.
Workflow
- [ ] 1. Inventory existing conventions (spec, naming, error shape, auth, pagination)
- [ ] 2. Model resources and relationships before URLs
- [ ] 3. Write or update the OpenAPI 3.1 spec first
- [ ] 4. Choose methods, status codes, headers per operation
- [ ] 5. Define errors (RFC 9457), pagination, filtering, idempotency
- [ ] 6. Lint the spec and diff it against the last release for breaking changes
- [ ] 7. Implement, then validate responses against the spec in tests
1. Inventory before designing
Read the existing spec (openapi.yaml, openapi.json, or generated from code) and two or
three existing handlers. Consistency with the current API beats theoretical purity: if the
API uses snake_case JSON and offset pagination, a new endpoint does too unless the user
agrees to migrate. Record: casing, ID format, timestamp format, envelope (or none), error
shape, auth scheme, pagination style.
2. Model resources
- Nouns, plural collections:
/orders,/orders/{orderId},/orders/{orderId}/items. - Nest at most one level. Deeper relationships get top-level resources with filters:
/items?orderId=..., not/customers/{c}/orders/{o}/items/{i}. - Actions that are not CRUD become sub-resources or state transitions:
POST /orders/{id}/cancellation(creates a cancellation) beatsPOST /cancelOrder. A pragmaticPOST /orders/{id}:cancelis acceptable if the API already uses it. - IDs are opaque strings (UUIDv7, ULID, or prefixed like
ord_8f3k...). Never expose auto-increment integers on public APIs: they leak volume and invite enumeration. - Timestamps: RFC 3339 in UTC (
2026-03-14T09:26:53Z). Money: integer minor units plus ISO 4217 currency ({"amount": 1999, "currency": "EUR"}), never floats.
3. Spec first
Write the OpenAPI 3.1 change before code. Each operation needs: operationId
(verbNoun, unique), summary, parameters with required and schema, request body schema,
every response code it can return, and examples. Put shared schemas in
components/schemas, shared errors in components/responses.
4. Methods and status codes
| Method | Semantics | Success code | Safe / idempotent |
|---|---|---|---|
| GET | Read | 200 (304 with ETag match) | yes / yes |
| POST (collection) | Create | 201 + Location header + body | no / no (make it so with Idempotency-Key) |
| POST (action) | Process | 200 with result, 202 if async | no / no |
| PUT | Full replace (or create at known ID) | 200 or 204; 201 if created | no / yes |
| PATCH | Partial update (JSON Merge Patch, RFC 7396) | 200 with resource | no / not guaranteed |
| DELETE | Remove | 204 (also on repeat delete, or 404, pick one and document) | no / yes |
Error codes, chosen precisely:
| Code | Use for |
|---|---|
| 400 | Malformed syntax: unparseable JSON, wrong type in query param |
| 401 | Missing or invalid credentials (send WWW-Authenticate) |
| 403 | Authenticated but not allowed |
| 404 | Not found, and also for resources the caller may not know exist (avoid leaking existence) |
| 405 | Method not supported on this path (send Allow) |
| 409 | State conflict: duplicate unique key, invalid state transition |
| 412 | If-Match precondition failed (optimistic concurrency) |
| 415 | Unsupported Content-Type |
| 422 | Well-formed but semantically invalid (validation errors) |
| 428 | Precondition required: endpoint demands If-Match |
| 429 | Rate limited (send Retry-After) |
| 500 / 502 / 503 / 504 | Server bug / bad upstream / overloaded or maintenance (with Retry-After) / upstream timeout |
Never return 200 with {"success": false}. Never return 500 for client mistakes; a spike
in 500s must mean the server is broken.
5. Errors, pagination, filtering, idempotency
Errors use RFC 9457 Problem Details with Content-Type: application/problem+json:
{
"type": "https://api.example.com/problems/validation-error",
"title": "Request validation failed",
"status": 422,
"detail": "2 fields are invalid.",
"instance": "/orders",
"traceId": "4bf92f3577b34da6",
"errors": [
{ "pointer": "/items/0/quantity", "code": "min", "detail": "must be >= 1" }
]
}
type is a stable URI clients switch on; title does not vary per occurrence; extension
members (errors, traceId) are allowed. Never put stack traces or SQL in detail.
Pagination: default to cursor pagination for anything that grows.
GET /orders?limit=50&cursor=eyJpZCI6Im9yZF8xMjMifQ
200 OK
{ "data": [...], "nextCursor": "eyJpZCI6Im9yZF8xNzMifQ", "hasMore": true }
- Cursor is opaque (base64 of the last sort key plus tie-breaker ID). Sort must be total:
ORDER BY created_at DESC, id DESC. - Enforce
limitmax (e.g. 100) and a default (e.g. 20). Reject or clamp above max; document which. - Offset pagination only for small, bounded admin lists where page jumping matters.
Filtering and sorting: ?status=paid&createdAfter=2026-01-01T00:00:00Z&sort=-createdAt.
Whitelist filterable and sortable fields; every allowed combination needs an index.
Unknown query params: reject with 400 on new APIs so typos do not silently return everything.
Idempotency: accept Idempotency-Key on POST endpoints that create or charge. Store
key + request hash + response for 24 h; replay the stored response on retry; return 409
if the same key arrives with a different body or while the first request is in flight.
Concurrency: return ETag on GET; require If-Match on PUT/PATCH for resources
edited by multiple actors; respond 412 on mismatch.
For detailed error catalogs, cursor encoding, bulk endpoints, long-running operations and rate-limit headers, read references/patterns.md when designing any of those.
6. Lint and diff the spec
npx @redocly/cli lint openapi.yaml # structural + style rules
npx @stoplight/spectral-cli lint openapi.yaml # alternative ruleset engine
oasdiff breaking main-openapi.yaml openapi.yaml # fails on breaking changes
Breaking changes (require a new version or a negotiated migration): removing or renaming
a field, endpoint, or enum value clients send; adding a required request field; narrowing
a type; changing a status code clients rely on; changing default sort or page size.
Non-breaking: adding optional request fields, adding response fields, adding endpoints.
Adding a response enum value is breaking for clients with exhaustive switches; document
that enums are open (x-extensible-enum or prose) from day one.
7. Versioning and deprecation
- Default: major version in the path (
/v1/), bumped only for breaking changes, with additive evolution inside a version. Header or date-based versioning (Api-Version: 2026-01-15) is fine when the team can maintain per-version transforms. - Deprecate with the
Deprecationheader (RFC 9745) andSunsetheader (RFC 8594) plus aLink: <...>; rel="deprecation"to migration docs. Log usage of deprecated endpoints per client before removal.
Hard rules
- Use the same casing, envelope, and error shape everywhere. Mixed conventions cost every client forever.
- Validate input at the edge against the same schema the spec publishes; reject unknown fields on write if mass assignment is a risk.
- Return the created or updated resource from POST/PUT/PATCH so clients avoid a re-fetch.
- Lists never return unbounded results, even internal ones.
- Authorization is per object: checking “is logged in” without “owns this order” is BOLA, the top API vulnerability.
GETnever changes state. Prefetchers, crawlers and retries will call it.- Do not ship a breaking change without explicit user confirmation and a migration note.
Verification
- Spec lints clean (
redocly lintorspectral lint, zero errors). oasdiff breakingagainst the last released spec reports nothing, or each item is an intentional, approved breaking change.- Contract tests validate real responses against the spec (e.g. schemathesis
st run openapi.yaml --url http://localhost:8000or an OpenAPI response validator middleware in the test suite). - Manually exercise: create twice with the same
Idempotency-Key, page through a list while inserting rows, send invalid input and confirm a 422 problem document.
Output contract
For a design or review, produce:
## Endpoint summary
| Method | Path | Purpose | Success | Errors |
## Schema changes
(OpenAPI diff or snippet)
## Breaking-change check
oasdiff result + intentional breaks with migration plan
## Open questions Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/patterns.md
# REST patterns appendix
Read the section you need; each is independent.
## Error type catalog
Keep a small, documented set of problem `type` URIs. Clients switch on `type`, never on
`title` or `detail`.
| type suffix | status | When |
|---|---|---|
| `validation-error` | 422 | Body or params fail schema/business validation; include `errors[]` with JSON Pointers |
| `malformed-request` | 400 | Unparseable JSON, wrong content type for body |
| `unauthenticated` | 401 | Missing/expired token |
| `forbidden` | 403 | Lacks permission or scope; include `requiredScope` if safe |
| `not-found` | 404 | Resource absent or not visible to caller |
| `conflict` | 409 | Unique constraint, invalid state transition (`currentState` extension helps) |
| `idempotency-key-reused` | 409 / 422 | Same key, different payload |
| `precondition-failed` | 412 | ETag mismatch |
| `rate-limited` | 429 | Include `Retry-After` header |
| `internal` | 500 | Generic; include `traceId` only |
Serve a human-readable page at each `type` URI. `about:blank` is the default `type` when
nothing more specific applies; then `title` must be the HTTP reason phrase.
## Cursor encoding
```python
import base64, json
def encode_cursor(created_at: str, id_: str) -> str:
raw = json.dumps({"c": created_at, "i": id_}, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def decode_cursor(cursor: str) -> tuple[str, str]:
pad = "=" * (-len(cursor) % 4)
data = json.loads(base64.urlsafe_b64decode(cursor + pad))
return data["c"], data["i"]
```
Keyset query (descending):
```sql
SELECT * FROM orders
WHERE tenant_id = $1
AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT $4 + 1; -- fetch one extra to compute hasMore
```
Index: `(tenant_id, created_at DESC, id DESC)`. If cursors must be tamper-proof (they
encode tenant or filter state), sign them with HMAC and reject on mismatch. Bind the
cursor to the filter set: a cursor from `?status=paid` must not be reused with
`?status=open`.
Total counts are expensive on large tables. Offer `hasMore` by default; provide counts
only via a separate, cached endpoint or an approximate value.
## Long-running operations
```http
POST /exports -> 202 Accepted
Location: /exports/exp_42
{ "id": "exp_42", "status": "pending" }
GET /exports/exp_42 -> 200 { "status": "running", "progress": 0.4 }
GET /exports/exp_42 -> 200 { "status": "succeeded", "resultUrl": "..." }
```
Status enum: `pending | running | succeeded | failed | canceled`. Include `error` (a
problem document) on failure. Offer a webhook for completion when clients would poll for
minutes. Support `DELETE /exports/{id}` or `POST .../cancellation` for cancel.
## Bulk endpoints
- `POST /orders/batch` with up to N (e.g. 100) items. Return 200 with per-item results
(`[{ "index": 0, "status": 201, "data": {...} }, { "index": 1, "status": 422, "error": {...} }]`)
or 207-style semantics documented clearly. Decide all-or-nothing vs partial up front
and say so in the spec.
- Enforce item and payload limits; return 413 when exceeded.
## Rate-limit headers
Return `429` with `Retry-After` (seconds). Many APIs also send
`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (older IETF draft names) or
the newer `RateLimit-Policy` / `RateLimit` structured fields from the IETF httpapi draft.
Pick one, document it, and keep it stable. Limit per credential, not per IP, for
authenticated APIs.
## Field selection and expansion
- Sparse fieldsets: `?fields=id,status,total` reduce payload for mobile clients.
- Expansion: `?expand=customer,items.product` inlines related resources; cap depth at 2
and whitelist expandable paths to avoid accidental N+1 fan-out.
## PATCH formats
- JSON Merge Patch (RFC 7396, `application/merge-patch+json`): send the fields to change;
`null` deletes. Simple, cannot express array element edits.
- JSON Patch (RFC 6902, `application/json-patch+json`): list of ops (`add`, `remove`,
`replace`, `test`). Use when clients must edit array elements or need atomic `test`.
## Caching
- `ETag` (strong or weak) plus `Cache-Control: private, max-age=0, must-revalidate` for
user-specific resources; clients send `If-None-Match` and get 304.
- Public, rarely changing resources: `Cache-Control: public, max-age=300`.
- Never cache responses that vary by `Authorization` in shared caches unless `Vary` and
`private` are set correctly.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.