API design

SkillMedia

Use when settling the contract of an API you expose, before implementation: resources/URLs, REST vs GraphQL, versioning, one RFC 9457 error envelope, pagination, idempotency — emitted as OpenAPI 3.1. NOT implementing the endpoints (that is `fastapi`/`nestjs`/`go`/`nodejs`), NOT auth hardening (that is `secure-coding`), NOT consuming a third-party API (that is `api-connector-builder`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the API design skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/api-design/SKILL.md and read by ahel’s review.

Your one job

You design the contract an API exposes. You do not write the handler. The deliverable is a set of decisions a backend skill can implement directly: resource shapes, URLs, methods, the status-code map, one error envelope, pagination params, versioning rules — ideally captured as an OpenAPI 3.1 document.

When the user names a framework (FastAPI, NestJS, Go, Node), they own the build; you are pulled in for contract questions. Settle the contract first, then hand off (see Handoff). Keep every decision framework-neutral: nothing here should mention an ORM, a router, or a DI container.

REST vs GraphQL vs hybrid

Pick on traffic shape, not fashion. Decide once, write it down.

SituationChooseWhy
CRUD-ish resources, public API, HTTP caching mattersRESTURLs map to resources; CDN/proxy caching works on GET + ETag out of the box
Many client shapes, deep nested graphs, mobile over-fetch is realGraphQLone round-trip, client picks fields; no N endpoints per screen
Stable resource API + one rich read surface for a client appHybridREST for the system of record, a GraphQL read layer on top

Operational gotcha that decides monitoring: GraphQL returns HTTP 200 even when a field errored — failures live in an errors[] array next to partial data. Your dashboards cannot alert on 5xx; you must alert on the errors[] payload. REST signals failure with the HTTP status itself. If your ops team lives on status-code SLOs, that is a point for REST. Schema/nullability design, mutation and error-union conventions, and this error model in full: references/graphql-design.md.

Resource & URL modeling

Resources are nouns; HTTP methods are the verbs. Never put a verb in a path.

Rules, each with its reason:

  • Plural collections, consistent everywhere/projects, /projects/{id}. Pick plural and never mix singular in; inconsistent naming is the most-cited design smell.
  • Nest sub-resources one level/projects/{id}/tasks. Deeper than one level (/projects/{p}/tasks/{t}/comments/{c}) gets unreadable; link to the flat resource instead (/comments/{c}).
  • Methods carry intentGET read, POST create, PUT full replace, PATCH partial update, DELETE remove. A path never says what it does.
  • Filter/sort/select via query string, not new paths?status=open&sort=-created_at&fields=id,title. One GET /projects handles all of it; don't mint /projects/open and /projects/byDate.
BadGoodWhy
POST /createProjectPOST /projectsthe method is the verb
GET /getUserOrders/{id}GET /users/{id}/ordersnoun hierarchy, no verb
GET /project and GET /tasksGET /projects and GET /tasksone plural convention
GET /projects/activeGET /projects?status=activefilter is a query param
POST /projects/{id}/deleteDELETE /projects/{id}method, not path segment

Full query grammar (filter operators, sparse fieldsets, sort syntax), content negotiation, rate-limit headers and a HATEOAS note: references/rest-conventions.md.

Status codes that matter

You need a small map, used consistently. Don't overload 200.

200 OK            # read / update succeeded, body returned
201 Created       # resource created — include Location: /projects/{id}
202 Accepted      # async accepted, not done — return a status URL
204 No Content    # success, nothing to return (e.g. DELETE)
400 Bad Request   # malformed syntax / unparseable
401 Unauthorized  # not authenticated — who are you?
403 Forbidden     # authenticated but not allowed — I know you, no
404 Not Found     # resource absent (or hidden from this caller)
409 Conflict      # state collision — duplicate, version mismatch
422 Unprocessable # syntactically fine, semantically invalid (validation)
429 Too Many Req  # rate limited — include Retry-After
5xx               # your fault, never the client's; never leak the stack

Two distinctions agents get wrong:

  • 401 vs 403 — 401 means unauthenticated (no/invalid credentials); 403 means authenticated but unauthorized. Returning 401 on a permission failure leaks that re-auth might help when it won't.
  • 409 vs 422 — 409 is a state conflict (the request fights the current server state: dup key, stale version). 422 is a content problem (the body parses but fails business rules). Full status-code table with when-each in references/rest-conventions.md.

Error envelope: RFC 9457

One error shape across every endpoint. Adopt RFC 9457 Problem Details (the current standard; it obsoletes RFC 7807). Media type application/problem+json. Standard members: type, title, status, detail, instance, plus your own extension members.

{
  "type": "https://api.acme.com/problems/validation-error",
  "title": "Your request parameters didn't validate.",
  "status": 422,
  "detail": "due_date must be in the future.",
  "instance": "/projects/8a3/tasks",
  "errors": [
    { "field": "due_date", "message": "must be in the future" }
  ],
  "correlation_id": "req_01H..."
}

Rules:

  • type is a stable, machine-readable URI — clients branch on it, not on detail. Never change a type string once published.
  • title is human, generic per type; detail is human, specific to this occurrence. detail is for people, not parsers.
  • Always carry a correlation/request id (extension member) so a support ticket maps to a log line.
  • Never leak internals — no stack traces, SQL, internal hostnames, or raw DB ids in detail. That is both an information leak and a coupling leak.

Pagination

Default to cursor (keyset) pagination. Use offset only for small, bounded sets.

ApproachUse whenWhy
Cursor / keysetlarge or changing datasets, feeds, anything hotopaque token over an indexed ordered column → constant-time, stable across inserts
Offset / limitsmall bounded admin lists, fixed reference tablessimple, but the DB scans-and-discards skipped rows (degrades with depth) and skips or duplicates rows when data shifts between page loads

Decision line: if the list can grow unbounded or rows can be inserted between page fetches, use cursor.

REST cursor envelope — same keys on every list endpoint:

{
  "data": [ { "id": "...", "title": "..." } ],
  "next_cursor": "eyJpZCI6MTI4N30",
  "has_more": true
}

The next page is GET /projects?cursor=eyJpZCI6MTI4N30&limit=50. The cursor is opaque — clients must not parse or construct it.

GraphQL has its own de-facto standard: Relay Connectionsedges { node, cursor }, pageInfo { hasNextPage, endCursor }, args first / after. Use it; don't invent a bespoke GraphQL pagination shape. See references/graphql-design.md.

Versioning & evolution

Prefer additive, non-breaking evolution over a new version. A new version forks your client base and your maintenance. Most changes don't need one.

  • Non-breaking (no version bump): adding an optional field, adding a new endpoint, adding a new optional query param, adding a new enum value clients are told to tolerate.
  • Breaking (needs a version): removing/renaming a field, changing a type, making an optional field required, changing status-code semantics, changing the error type for a case.

When you must version, use a URL path version (/v1/...) for public APIs — it is visible, cacheable, trivially testable in a browser, and the most common convention clients expect. Header/media-type versioning (Accept: application/vnd.acme.v2+json) keeps URLs clean but is harder to test and cache; query-param versioning (?version=2) pollutes every URL. Default to path.

Deprecate gracefully with the Deprecation and Sunset response headers so clients get programmatic warning before removal:

Deprecation: true
Sunset: Sat, 31 Oct 2026 23:59:59 GMT
Link: <https://api.acme.com/v2/projects>; rel="successor-version"

Full breaking-vs-non-breaking matrix, the three versioning mechanisms and the deprecation/sunset workflow: references/versioning-and-evolution.md.

Idempotency & concurrency

  • Make POST/PATCH retry-safe with an Idempotency-Key header. The client sends a unique key; the server replays the original response on a retry instead of double-creating. This is an IETF httpapi draft (not yet an RFC) but is the proven pattern across Stripe, PayPal, and others — adopt it for any create/charge/payment-like operation where a network retry could duplicate work.
POST /payments
Idempotency-Key: 9b1f7c2e-...
  • Use ETag + If-Match for optimistic concurrency on PUT/PATCH. The server returns an ETag (a version fingerprint) on read; the client sends it back in If-Match on write. If it no longer matches, the server returns 412 Precondition Failed — no lost update. Use If-None-Match for conditional GET caching.

Anti-patterns

Anti-patternWhy it bitesDo instead
Verbs in paths (/getUsers, /createProject)duplicates HTTP semantics, breaks caching/toolingnoun + HTTP method
Mixed plural/singular collectionsclients can't predict URLsone plural convention everywhere
200 on error (REST)breaks status-code monitoring and client error handlingreal 4xx/5xx + RFC 9457 body
Different error shape per endpointevery client writes per-endpoint parsingone application/problem+json shape
Leaking stack traces / SQL / DB ids in errorsinfo leak + couples clients to internalsgeneric title, safe detail, correlation id
Offset pagination on a hot/large feedslow at depth; skips/dups rows on insertcursor/keyset pagination
Unbounded list endpoint (no limit)one client can pull the whole tableenforce a default + max limit
New version for every changeforks clients, multiplies maintenanceadditive non-breaking evolution
Breaking a field in place on a live versionsilently breaks existing clientsnew field/version + deprecation headers
401 for a permission failuremisleads client into re-authing403 when authenticated-but-forbidden
200 for a created resourcehides the create, no Location201 + Location header
Ignoring GraphQL partial errors[]failures invisible to monitoringalert on errors[], not just HTTP 5xx

Handoff

The contract is the artifact. Emit it as an OpenAPI 3.1 document — the checkable deliverable a framework skill generates code from. How to shape it, and what scripts/verify.sh checks: references/openapi-contract.md.

Hand off to the builder:

Adjacent concerns you do not own:

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
api-design-ericrisco
Source
github.com/ericrisco/rsc-harness