Backend & APIs No. 096Editor's pick

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.

  • namerest-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

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.

~/.claude/skills/rest-api-design/SKILL.md

--- name: rest-api-design description: Designs and reviews HTTP/REST APIs contract-first - resource modeling, URL and method semantics, status codes, cursor pagination,… license: MIT ---

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) beats POST /cancelOrder. A pragmatic POST /orders/{id}:cancel is 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

MethodSemanticsSuccess codeSafe / idempotent
GETRead200 (304 with ETag match)yes / yes
POST (collection)Create201 + Location header + bodyno / no (make it so with Idempotency-Key)
POST (action)Process200 with result, 202 if asyncno / no
PUTFull replace (or create at known ID)200 or 204; 201 if createdno / yes
PATCHPartial update (JSON Merge Patch, RFC 7396)200 with resourceno / not guaranteed
DELETERemove204 (also on repeat delete, or 404, pick one and document)no / yes

Error codes, chosen precisely:

CodeUse for
400Malformed syntax: unparseable JSON, wrong type in query param
401Missing or invalid credentials (send WWW-Authenticate)
403Authenticated but not allowed
404Not found, and also for resources the caller may not know exist (avoid leaking existence)
405Method not supported on this path (send Allow)
409State conflict: duplicate unique key, invalid state transition
412If-Match precondition failed (optimistic concurrency)
415Unsupported Content-Type
422Well-formed but semantically invalid (validation errors)
428Precondition required: endpoint demands If-Match
429Rate limited (send Retry-After)
500 / 502 / 503 / 504Server 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 limit max (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 Deprecation header (RFC 9745) and Sunset header (RFC 8594) plus a Link: <...>; 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.
  • GET never changes state. Prefetchers, crawlers and retries will call it.
  • Do not ship a breaking change without explicit user confirmation and a migration note.

Verification

  1. Spec lints clean (redocly lint or spectral lint, zero errors).
  2. oasdiff breaking against the last released spec reports nothing, or each item is an intentional, approved breaking change.
  3. Contract tests validate real responses against the spec (e.g. schemathesis st run openapi.yaml --url http://localhost:8000 or an OpenAPI response validator middleware in the test suite).
  4. 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 109 lines · 4.4 KB
# 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.

esc