---
name: api-design
description: Design and review HTTP/REST APIs in any back-end stack — resource modelling and naming, methods and status codes, RFC 9457 Problem Details errors, cursor vs offset pagination, filtering and sorting, idempotency keys, ETag/If-Match concurrency, versioning and backward compatibility, long-running operations, webhooks, rate limiting, OAuth2 scopes, and an OpenAPI-first workflow with Spectral linting. Use this whenever the user asks to design a new endpoint or API, write or review an OpenAPI/Swagger spec, choose status codes or an error format, add pagination, versioning or idempotency, plan a breaking change, design webhooks or async job endpoints, define rate limits, or asks "what should this URL look like" or "REST vs GraphQL vs gRPC". Also use it when reviewing controllers or route handlers for contract quality, and when deciding whether an event or message fits better than a synchronous API.
metadata:
  technology: Backend (general)
  type: architecture
---

# API Design

An API is a contract you cannot take back: once a client depends on a field, a status code or an ordering, it is yours forever. Design from the consumer's use cases, make the contract explicit in OpenAPI before code, and make every change additive.

## 1. Choose the style first

| Need | Choose | Why |
|---|---|---|
| Public/partner API, CRUD-ish resources, cacheable | **REST over HTTP + JSON** | Universal tooling, HTTP caching, easy to evolve |
| Many clients needing different shapes of a rich graph (BFF, mobile) | **GraphQL** | Client-selected fields; budget query cost and depth |
| Internal service-to-service, low latency, streaming, strict schemas | **gRPC** | Protobuf contracts, HTTP/2, codegen; poor browser fit |
| Notify others that something happened; no reply needed | **Async events** (Kafka, SNS/SQS, AMQP) | Decoupled, resilient; document with AsyncAPI |

The rest of this skill covers REST; the compatibility, idempotency and auth rules apply to all styles.

## 2. Resources and naming

- Nouns, plural, lowercase kebab-case: `/customers/{customerId}/orders`. Nest at most one level; beyond that, use top-level resources with filters.
- IDs are opaque strings (UUID/ULID), never sequential integers leaked to the public.
- JSON fields in one case convention (camelCase is most common); timestamps in RFC 3339 UTC (`2026-03-01T12:00:00Z`); money as `{ "amount": "12.50", "currency": "EUR" }` (string or integer minor units, never float).
- Actions that don't map to CRUD become sub-resources or state changes: `POST /orders/{id}/cancellation`, not `POST /cancelOrder`.
- Enums are documented as extensible; clients must tolerate unknown values.

## 3. Methods and status codes

| Method | Semantics | Idempotent | Success codes |
|---|---|---|---|
| `GET` | Read | Yes | `200`, `304` |
| `POST` | Create / non-idempotent action | No (make it so with a key) | `201` + `Location`, `202`, `200` |
| `PUT` | Full replace (or create at known URI) | Yes | `200`, `204`, `201` |
| `PATCH` | Partial update (JSON Merge Patch, RFC 7396) | Not inherently | `200`, `204` |
| `DELETE` | Remove | Yes | `204`, `202` |

Errors: `400` malformed, `401` unauthenticated, `403` authenticated but not allowed, `404` not found (also for resources the caller may not see, to avoid leaking existence), `409` state conflict, `412` precondition failed, `415`, `422` semantic validation, `428` precondition required, `429` rate limited, `500`, `503` + `Retry-After`. Never `200` with an error body.

## 4. Errors (RFC 9457 Problem Details)

```http
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{ "type": "https://api.example.com/problems/validation",
  "title": "Request validation failed", "status": 422,
  "detail": "2 fields are invalid", "instance": "/orders",
  "traceId": "4bf92f3577b34da6",
  "errors": [{ "pointer": "/items/0/quantity", "detail": "must be >= 1" }] }
```

`type` is a stable, documented identifier clients branch on; `title`/`detail` are for humans. No stack traces or SQL in any environment reachable by clients.

## 5. Collections: pagination, filtering, sorting

| Choose | When | Shape |
|---|---|---|
| **Cursor (keyset)** — default | Large or changing data, feeds, infinite scroll | `?limit=50&cursor=eyJpZCI6...` → `{ "data": [], "nextCursor": "…" }` |
| **Offset** | Small, stable sets needing "jump to page N" and a total | `?page=3&pageSize=50` → include `totalCount` only if cheap |

- Server enforces a default and maximum `limit`; cursors are opaque and encode the sort key plus a unique tiebreaker.
- Filtering: `?status=open&createdAfter=2026-01-01`; sorting: `?sort=-createdAt,name`. Allow-list sortable/filterable fields so each is backed by an index.
- Sparse fieldsets (`?fields=id,name`) only when payloads are demonstrably heavy.

## 6. Idempotency and concurrency

- `POST` that creates or charges accepts an **`Idempotency-Key`** header: store key + request hash + response for 24 h+; a replay returns the stored response; same key with a different body returns `422`; concurrent in-flight duplicate returns `409`.
- Optimistic concurrency: `GET` returns `ETag`; `PUT`/`PATCH`/`DELETE` send `If-Match`; mismatch → `412`; missing when required → `428`. This prevents lost updates between clients.

## 7. Versioning and compatibility

| Strategy | Use when | Trade-off |
|---|---|---|
| **URI major version** `/v1/…` | Public APIs; default | Visible, cache-friendly, coarse |
| Header / media type `Accept: application/vnd.x.v2+json` | Fine-grained representation versions | Harder to test and cache |
| Date-based version header | Many small breaking changes over time | Needs per-version transformation layer |
| No version, additive evolution only | Internal APIs with known consumers | Requires discipline and consumer contract tests |

**Non-breaking:** adding optional request fields, response fields, endpoints, enum values (if documented as extensible). **Breaking:** removing/renaming fields, changing types or formats, making optional fields required, tightening validation, changing status codes, defaults or pagination semantics. Breaking changes ship as a new major version; deprecate the old with `Deprecation` and `Sunset` headers, a migration guide and usage metrics before removal.

## 8. Long-running operations

`POST /reports` → `202 Accepted` + `Location: /operations/{opId}`. `GET /operations/{opId}` returns `{ "status": "running|succeeded|failed", "resultUrl": "...", "error": {...} }` with `Retry-After` for polling. Offer a webhook callback for completion when clients shouldn't poll.

## 9. Webhooks

- Sign every payload (HMAC-SHA256 over timestamp + body) and send the signature and timestamp in headers; receivers verify with constant-time comparison and reject stale timestamps (e.g. > 5 min) to stop replays.
- At-least-once delivery: include a unique event `id` for dedup; retry with exponential backoff + jitter for ~24–72 h; dead-letter and let consumers replay.
- Thin events (`id`, `type`, resource URL) are safer than full payloads; version the event schema.

## 10. Rate limiting and security

- Return `429` with `Retry-After`; expose limits with `RateLimit-Limit`/`RateLimit-Remaining`/`RateLimit-Reset` (or the IETF `RateLimit`/`RateLimit-Policy` draft headers); document per-client quotas.
- OAuth 2.x bearer tokens (authorization code + PKCE for users, client credentials for services); fine-grained scopes (`orders:read`, `orders:write`) checked per operation, plus object-level ownership checks on every ID.
- TLS only; no secrets or tokens in query strings; validate request bodies against the schema; cap body size.

## 11. OpenAPI-first workflow

1. Write or change the OpenAPI 3.1 spec first; review it in the PR like code.
2. Lint with **Spectral** (plus a house ruleset: naming, Problem Details on every 4xx/5xx, pagination on lists, `operationId`s, security on every operation).
3. Detect breaking changes in CI by diffing against the published spec (e.g. oasdiff).
4. Generate server stubs/clients or validate requests against the spec; publish docs and mocks from it.

## 12. Deliverable format

```
## API design review: <API name>
Style decision: REST | GraphQL | gRPC | events — <one-line reason>

| # | Severity | Operation / path | Issue | Recommendation |
|---|---|---|---|---|
| 1 | blocker | POST /payments | No idempotency; retries double-charge | Require Idempotency-Key |

Breaking-change assessment: none | <list + migration plan>
```

Then the proposed contract as an OpenAPI snippet:

```yaml
paths:
  /orders:
    get:
      operationId: listOrders
      security: [{ oauth2: [orders:read] }]
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        '200': { $ref: '#/components/responses/OrderPage' }
        '4XX': { $ref: '#/components/responses/Problem' }
```

## Anti-patterns to reject

- Verbs in paths (`/getOrders`, `/createUser`) and RPC-over-POST for everything.
- `200 OK` with `{ "success": false }`; bespoke error shapes per endpoint.
- Unbounded list endpoints; offset pagination over large, fast-changing tables.
- Non-idempotent `POST` for payments or orders with client retries enabled.
- Renaming or removing fields in place "because no one uses it".
- Sequential integer IDs and existence leaks via `403` vs `404`.
- Code-first APIs with a generated spec nobody reviews or lints.
- Tokens or PII in URLs; scopes so coarse that every client gets `admin`.
