A public HTTP API is not a function with extra headers. It is a contract over a protocol that already has methods, cache rules, and status classes. Ignore those and you reinvent RPC on port 443, then spend years documenting the exceptions.
██╗ ██╗████████╗████████╗██████╗ █████╗ ██████╗ ██╗ ██║ ██║╚══██╔══╝╚══██╔══╝██╔══██╗ ██╔══██╗██╔══██╗██║ ███████║ ██║ ██║ ██████╔╝ ███████║██████╔╝██║ ██╔══██║ ██║ ██║ ██╔═══╝ ██╔══██║██╔═══╝ ██║ ██║ ██║ ██║ ██║ ██║ ██║ ██║██║ ██║ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝
HTTP Semantics (RFC 9110) defines what a method means, when a response is cacheable, and which status class a client should treat as retryable. Your framework's router does not override that. If you POST to create and return 200 with a body that changes on every retry, you have left the protocol and you will pay for it in client libraries.
REST is a set of constraints (Fielding). Most "REST APIs" are resource-oriented HTTP. That is fine. Be honest about which constraints you kept: uniform interface, stateless requests, cacheable responses. If every call is a POST to /rpc, say it is RPC.
CLIENT
method + target + headers + optional body
│
▼
BROWSER / APP CACHE (RFC 9111)
│
▼
CDN / EDGE (Cache-Control, Vary, auth)
│
▼
ORIGIN GATE (TLS terminate, WAF, rate limit)
│
▼
APP HANDLER (authn, authz, validate, mutate)
│
▼
STORE / QUEUE (commit or enqueue)
│
▼
RESPONSE status + headers + problem or resource
| Method | Safe | Idempotent | Use |
|---|---|---|---|
| GET | Yes | Yes | Read. No side effects. Cacheable when headers allow. |
| HEAD | Yes | Yes | Metadata only. Same headers as GET, empty body. |
| PUT | No | Yes | Replace the resource at this URL with this representation. |
| DELETE | No | Yes | Remove. A second DELETE is still success (404 or 204, pick one and keep it). |
| POST | No | No | Process. Create, trigger, or anything that is not a replacement. |
| PATCH | No | Conditional | Partial update (RFC 5789). Idempotent only if your patch language is. |
Use POST when the protocol has no safer verb: creating a resource whose URL the server assigns, kicking a workflow, or accepting a command. If you can PUT a known URL, do that. Clients and caches understand PUT. They cannot infer anything from a zoo of POST paths.
Returning 200 with {"error": true} trains every client to parse bodies for control flow. Load balancers, caches, and synthetic checks look at the status line. If the status lies, the rest of the stack lies with it.
Mobile clients, gateways, and your own jobs retry on timeout. A timeout is not a known failure. The request may have committed. POST without an idempotency key double-charges. PUT to a known URL does not, if replace is truly replace.
The usual pattern: client sends Idempotency-Key (or Idempotency-Key via a documented header). Server stores the key with the request hash and the response. A replay with the same key and body returns the stored response. A replay with the same key and a different body is 409.
type Stored = { hash: string; status: number; body: unknown };
async function withIdempotency(
key: string,
bodyHash: string,
store: Map<string, Stored>,
work: () => Promise<{ status: number; body: unknown }>
) {
const hit = store.get(key);
if (hit) {
if (hit.hash !== bodyHash) {
return { status: 409, body: { type: "https://api.example/conflicts/idempotency" } };
}
return { status: hit.status, body: hit.body };
}
const result = await work();
store.set(key, { hash: bodyHash, status: result.status, body: result.body });
return result;
}
sequenceDiagram participant C as Client participant G as API gate participant D as Store C->>G: POST /charges Idempotency-Key=k1 G->>D: lookup k1 D-->>G: miss G->>D: insert pending + commit charge G-->>C: 201 Charge C->>G: POST /charges Idempotency-Key=k1 (retry) G->>D: lookup k1 D-->>G: stored 201 G-->>C: 201 Charge (same body)
Problem Details (RFC 9457, successor to 7807) gives you type, title, status, detail, and instance. The type is a URI your clients can switch on. Human copy lives in title and detail. Do not invent a third error envelope per team.
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 409,
"detail": "Account acc_9f cannot cover this charge.",
"instance": "/charges/ch_01J",
"account_id": "acc_9f"
}
A 400 that says "invalid body" wastes a round trip. Point at the field. If you need a machine-readable list, add an extension member (RFC 9457 allows it) and document it in the OpenAPI component.
Generate clients from a spec you publish, or generate the spec from types you own. Do not do neither. Breaking changes are: removing a field, changing a type, changing a status, renaming a path. Adding an optional field is not breaking if clients ignore unknown keys (they must).
Version in the media type (application/vnd.example.v2+json) or in the path (/v2). Header versioning works if every intermediary forwards it. Many CDNs and logs do not. Path versioning is ugly and survivable.
RFC 9111 decides whether a GET can be reused. Cache-Control: private, no-store on authenticated JSON. public, s-maxage=60, stale-while-revalidate=120 on a catalog. Vary: Authorization, Accept-Language if those change the body. A missing Vary is a cross-user leak waiting for a shared cache.
Authorization belongs in a header or a signed cookie, not in a query string that lands in CDN logs. Prefer bearer tokens that the origin validates. If you terminate auth at the edge, the origin must still not trust an unverified internal header unless the network path is locked.
flowchart TD
R[Request] --> A{Auth present?}
A -->|no, public GET| C{Fresh in CDN?}
C -->|yes| H[200 from cache]
C -->|no| O[Origin]
A -->|yes| E[Edge verify or pass through]
E --> O
O --> V[Handler]
V -->|GET cacheable| S[Set Cache-Control + ETag]
V -->|POST mutate| I[Idempotency + 2xx/4xx]
Document the origin time budget. A client that waits 100 seconds on a 10-second origin will retry into a pileup. Return 503 with Retry-After when you shed load. Returning a slow 200 after 55 seconds trains every mobile radio to hold a socket you cannot afford.