Imported from tedslittlerobot/stefs-claude-plugins (
plugins/arc-conventions/skills/api-design/SKILL.md). Install upstream withnpx skills add tedslittlerobot/stefs-claude-plugins --skill api-design. Copyright stays with the author.
API Design Conventions
These are the rules for every HTTP/JSON API a project builds. They are a record of current good
practice, not an implementation of any one specification — they borrow from JSON:API, RFC 9457,
and the public APIs people find easiest to use, and depart from each wherever the departure is
easier for a person to read, guess and debug. A project records its own chosen values and
registered exceptions in conventions/api.md; where that file and this skill disagree, the
project file wins (see the conventions skill for the two-layer model).
Principles
The first reader of an API is a person — reading its documentation, typing a curl command,
squinting at a response in a terminal, or reading a client's code a year later. Every rule below
follows from designing for that person first and for the machine second, because a machine will
cope with whatever it is given and a person will not.
- Guessable beats clever. Someone who has used one endpoint should be able to guess the shape
of the next. The same concept has the same name, type and format everywhere it appears — a
customer_idis never aclient_idin another resource, and a timestamp is never an epoch integer in one place and a string in another - Explicit beats implicit. Every documented key is always present, with
nullfor "no value"; array parameters look like arrays; ranges say whether they are inclusive. A reader should never have to know an unwritten rule to read a request or a response correctly - Strict in what it accepts. Unknown query parameters, unknown body fields, malformed values
and read-only fields are rejected with an error that names them, never silently ignored. This
deliberately inverts Postel's law: a lenient API turns typos into silent wrong answers —
?filter[stauts]=paidignored as unknown returns every order, and a script built on that result refunds, deletes or emails the lot. Leniency also becomes contract: once a client relies on a quirk being accepted, it cannot be removed - Errors are written to be acted on. An error says what was wrong, where, and how to fix it,
in a sentence a person can read, alongside a stable code a program can switch on. See
reference/errors.md - Full words, no abbreviations.
description,quantity,organisation_id— notdesc,qty,org_id. An abbreviation saves the writer a keystroke and costs every reader a guess, and two people abbreviate the same word differently - Don't leak the storage model. The API models resources as a client thinks of them, not as tables. Join tables, internal status flags, column names chosen for the database and auto-increment IDs are implementation details; exposing them makes every schema change an API change
- Safe, documented defaults. A request that omits an optional parameter gets the behaviour that is least surprising and least dangerous, and the documentation says what that is
- Usable from a terminal. Every endpoint can be exercised with
curland a bearer token: JSON in, JSON out, no bespoke encodings, no required client library. If an example in the documentation cannot be pasted into a shell and run, the design is the problem
Core Rules
These hold everywhere; the reference files give the detail and the reasoning.
- JSON in, JSON out,
Content-Type: application/json, UTF-8. No form-encoded or XML bodies - All keys and parameter names are
snake_case— JSON body keys, query parameters, path parameter names in documentation, error codes and enum values. One casing for everything that names data means no one has to remember which part of the payload uses which - URL path segments are
kebab-case—/line-items,/orders/{order_id}/bulk-cancel. A path is an address, and hyphens are the web's idiom for words in one. HTTP header names keep HTTP's ownHyphenated-Case - Arrays are always arrays — never comma-separated strings. In a JSON body that is a JSON
array. In a query string it is the parameter repeated with a
[]suffix:?filter[status][in][]=paid&filter[status][in][]=refunded. A single value is stillfilter[status][in][]=paid, an array of one. A comma-joined value is never split. A comma-joined string breaks the first time a value contains a comma (a tag, a name, an address), pushes a bespoke parsing step into every client and server, and makes an array indistinguishable from a scalar that happens to contain a comma. Seereference/lists.md - Every response body is an object with a top-level
dataorerrorkey —datafor success (an object for one resource, an array for a list),errorfor failure. Never a bare array, never a bare resource. A bare array cannot grow apaginationkey later without breaking every client - Paths are
/api, then any project or service prefix, then/meif applicable, then resources and verbs —/api/billing/me/invoices. The prefixes are listed in the project'sconventions/api.md. There is no version prefix - A route is a collection, a resource, or a verb. Collections are plural and
kebab-case(/api/posts,/api/posts/{post_id}). Create, read, update and delete are HTTP methods, never words in the path. Other operations arePOSTverbs, preferably scoped to a resource or collection (/api/orders/{order_id}/cancel), or to a non-resource area of responsibility (/api/auth/login); a top-level verb is acceptable where nothing scopes it - Nest a resource only when it is only or primarily reached through its parent
(
/api/posts/{post_id}/comments) or it is scoped to the current user (/api/me/posts), and one level deep at most. Everything else is top-level, with the relationship as a filter - The current user's own resources live under
/me/—/api/me/profile,/api/me/posts— with the user taken from the credentials, never from a parameter. An endpoint without/me/is general purpose: it may check the caller's permissions, but is never quietly narrowed to "the caller's own". Seereference/resources-and-fields.md - Related resources are returned as nested objects —
"customer": { ... }inside the order, alongsidecustomer_id— not flattened into copied fields, and not sideloaded into a separate top-level list - Lists keep a tidy top level: filters nest under
filter[...]— plain equality,[in][]for any of several values, and[gt]/[gte]/[lt]/[lte]for ranges — text search isq, and pagination is page-based by default withpageandper_page(default 25, maximum 150) and apaginationobject ofcurrent_page,per_page,total_pagesandtotal_items. Cursor pagination is for data that is very large, of unknown size, or volatile. Seereference/lists.md - IDs are strings in JSON, always — even when they are numbers underneath
- Timestamps are RFC 3339 strings in UTC with a
Z, named<event>_at("created_at": "2026-09-26T14:03:12Z"); calendar dates areYYYY-MM-DD, named<event>_on - Status codes mean what HTTP says they mean. A
200never carries an error; a failure is never reported with a success code. Seereference/methods-and-status-codes.md - A validation failure is a
422; a malformed request is a400. A422— a value fails an input constraint, and the user can fix it by changing an answer — lists every invalid parameter with a code and a message fit to show the user. A400is a bug in the client. Seereference/errors.md - Responses are gzipped for every client that sends
Accept-Encoding: gzip. Seereference/auth-and-limits.md - Out-of-range input errors loudly, now — a
per_pageof 500 is a422stating the maximum, never silently clamped to 150. A silent correction surfaces later, far from its cause, as data that seems to be missing - Adding is safe, changing is breaking — and avoid versioning. A new optional field or
endpoint is not a new version; renaming, removing or retyping anything is. When a resource
genuinely must break, the replacement is a new resource with a suffix —
/api/users-v2beside/api/users— never a version prefix on the whole API. Seereference/versioning.md
Before Designing an Endpoint
- Read the project's
conventions/api.mdif it exists — it carries the project's chosen values (the service prefixes after/api, ID format, which endpoints use cursor pagination and why) and any registered exceptions - Find the nearest existing endpoint for a similar resource and match its names and shapes; a new endpoint that is consistent with a slightly imperfect neighbour beats a perfect one that is not
- Write the example request and response first, as a
curlcommand and its JSON output, and read it as a newcomer would. If it needs explaining, change the design rather than the documentation
References
reference/resources-and-fields.md— URL anatomy and prefixes, what a route may name (collections, resources, verbs and scopes), kebab-case naming, nesting,/me/for the current user's resources, the response envelope, field naming, data types (IDs, timestamps, money, enums, booleans, nulls), nested related resources andinclude[], sparsefields[]reference/methods-and-status-codes.md— what each method means, request bodies,PATCHsemantics, action endpoints, success status codes, idempotency keys, optimistic concurrency, long-running and bulk operationsreference/lists.md— list and index endpoints: the list response, the top-level parameters, array query parameters,filter[...]and range operators,qsearch, sorting, page-based pagination and when to use cursors insteadreference/errors.md— the error body, error codes,400versus422, detail codes and field paths, the status code table, and what an error must never containreference/versioning.md— what is and is not a breaking change, what clients must tolerate, versioning a single resource with a-v<n>suffix, and deprecationreference/auth-and-limits.md— authentication headers,401versus403versus404, request IDs, rate limiting, CORS, caching headers, and gzip response compression
Related
documentationskill — its API-docs reference covers the OpenAPI file that is the contract for an API designed here, and theapi.mdoverview beside itlambdas-go/lambdas-nodeskills — an API route is served by anapi-<method>-<purpose>Lambda, one per route; this skill decides what that Lambda accepts and returnsinfrastructureskill — its frontend-hosting reference explains why every route sits under an/apipath prefixmysqlskill — shares thesnake_casenaming, which lets a field keep one name from column to JSON key; the rule above against leaking the storage model still decides whether a column is exposedconventionsskill — how the project's ownconventions/api.mdsits on top of this skill, and precedence when they disagree
