API Design Principles
The rules every endpoint follows: resources and methods, bare JSON with metadata in headers, status codes that mean something, and a Request-Id on all of it.
An API's real users are the people who integrate against it at 11 p.m. with a deadline. Every rule here exists to make their first guess correct: if they've used one endpoint, they can predict the next one without reading its docs. Consistency is the feature. A clever exception in one endpoint costs every integrator a lookup, forever.
Resources, Methods, Status Codes
- URLs name things, methods say what you're doing to them.
GET /uuid, notGET /generateUuid. Verb-shaped URLs push intent into names, where caches, retries and tooling can't see it. See REST & HTTP Verbs. - Status codes carry the outcome.
2xxmeans it worked,4xxmeans the client should change something,5xxmeans the server failed. Every HTTP client, proxy, monitor and retry library already understands that, so there's no"success": truein the body for clients to check twice and disagree with the status once. 400and422mean different things.400is a request the server couldn't parse: bad JSON, a missing header.422is a well-formed request with invalid values. A client can branch on the code alone: fix the format, or fix the data. See Error Responses.
Bodies Carry Resources, Headers Carry Metadata
A single resource is a JSON object. A list is a JSON array. No envelope.
{
"id": "01932c6c-7616-7590-a8db-cb4b0e4d1c8a",
"name": "Sample Object",
"created_at": "2026-10-22T14:23:45Z"
}
[
{ "id": "01932c6c-7616-7590-a8db-cb4b0e4d1c8a", "name": "Object A" },
{ "id": "0194a7f3-2b11-7e80-9c3d-4e8f1a2b5c70", "name": "Object B" }
]
The common alternative wraps every response in an envelope: the payload under a data key, with a sibling meta object for pagination and counts. I don't, because:
- The envelope is noise on every response. Every client unwraps
dataon every call, forever, to get the thing it asked for. - HTTP already has a place for metadata. Pagination goes in the
Linkheader (RFC 8288), rate limits inRateLimit, versions inApi-Version, tracing inRequest-Id. Generic HTTP tooling reads headers without knowing your body schema. - The same resource has the same shape everywhere. A payment looks the same in
GET /payments/{id}, in a list, and in a webhook. With envelopes, each context tends to grow its own wrapper.
GitHub's REST API works this way, and it's the most widely used example that unwrapped responses scale fine. JSON naming and types are in JSON Field Conventions.
Content Negotiation
JSON is the default. Where an endpoint can return another representation of the same resource, the client asks with Accept, rather than the API growing parallel URLs like /report.csv:
GET /reports/01932c6c-7616-7590-a8db-cb4b0e4d1c8a
Accept: text/csv
- One URL per resource, many representations. Links, caches and permissions don't fork per format.
- Responses that vary by
AcceptsendVary: Accept, so caches don't serve CSV to a client that asked for JSON. - A client asking for a format the endpoint can't produce gets
406 Not Acceptable, not JSON it didn't ask for.
Versioning
Clients pin a dated version with the Api-Version header. Additive changes ship to every version; breaking changes ship as a new version, with old ones announced for removal via Deprecation and Sunset. The URL identifies the resource, not the API revision. See Versioning & Deprecation.
Every Response Has a Request-Id
Request-Id: 01a1648f-c612-79f6-9e96-d0e9005fb00c
A unique ID on every response, success or error, logged by the server on every line it writes for that request. When an integrator reports a problem, the ID turns "it failed around 3 p.m." into a single search. Clients should log it next to their own errors. See Observability.
The header has no X- prefix: RFC 6648 deprecated the convention because "experimental" headers become permanent and then can never be renamed.
Request Headers
| Header | Purpose |
|---|---|
Content-Type |
application/json for request bodies |
Accept |
The representation the client wants (default application/json) |
Api-Version |
The dated API version to use |
Idempotency-Key |
Makes a POST safe to retry (Idempotency) |
User-Agent |
Who's calling, so their traffic can be found and contacted |
This Site
The syntaqx API at api.syntaqx.com follows every rule above, with the version only in the header (ADR 0002). Every response carries Request-Id, plus a legacy X-Request-ID with the same value for older clients. The site itself does content negotiation: request a post, a doc or the home page with Accept: text/markdown and you get its Markdown instead of HTML, from the same URL.
References
Specifications
- RFC 9110: HTTP Semantics, methods, status codes,
Acceptand406 - RFC 8288: Web Linking, the
Linkheader used for pagination - RFC 6648: Deprecating the "X-" Prefix, why
Request-Idhas noX-
Reference
- MDN: Content negotiation, how
Acceptpicks a representation - MDN: HTTP response status codes, what each status code means
In practice
- GitHub REST API: Getting started, bare arrays and objects with metadata in headers
- Stripe: Request IDs, the
Request-Idresponse header - Google AIP-121: Resource-oriented design, resources as nouns with standard methods
- Zalando RESTful API Guidelines, a thorough public rulebook to compare against