Systems · HTTP · APIs
Request → Contract
HTTP API emblem

HTTP API Architecture

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.

Barnabas Waweru 26 August 2026 17 min read
ansi · wordmark · http api
██╗  ██╗████████╗████████╗██████╗    █████╗ ██████╗ ██╗
██║  ██║╚══██╔══╝╚══██╔══╝██╔══██╗  ██╔══██╗██╔══██╗██║
███████║   ██║      ██║   ██████╔╝  ███████║██████╔╝██║
██╔══██║   ██║      ██║   ██╔═══╝   ██╔══██║██╔═══╝ ██║
██║  ██║   ██║      ██║   ██║       ██║  ██║██║     ██║
╚═╝  ╚═╝   ╚═╝      ╚═╝   ╚═╝       ╚═╝  ╚═╝╚═╝     ╚═╝

HTTP Is the Architecture

RFC 9110 is not optional reading

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.

An HTTP request as a cyan glass dart piercing layered dark-glass membranes
One request crosses client cache, CDN, origin, and store. Each layer can answer, mutate, or fail. The status code has to mean the same thing at every layer.
1
Client
Method, target, headers, optional body. Idempotency-Key on unsafe POST.
2
Cache / edge
RFC 9111 reuse or miss. Auth and Vary decide whether a shared cache may answer.
3
Handler
Validate, authorize, mutate or read. Status class is the control plane.
4
Response
Resource, 202+queue, or problem+json. Cache-Control belongs here, not in a later middleware guess.
schema · request layers
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

Methods Are Semantics

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.
POST is the escape hatch, not the default

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.

Status Classes

2xx Success
201 with Location on create. 202 when the work is queued. 204 when there is no body. 200 is not a universal solvent.
3xx Redirect
304 for conditional GET. 301/308 change the permanent URL. Do not 302 a POST unless you want a confused client.
4xx Client
400 validation. 401 unauthenticated. 403 authenticated but forbidden. 404 unknown. 409 conflict. 412 precondition. 429 rate limit.
5xx Server
500 unexpected. 502/504 upstream. 503 overload with Retry-After. Clients may retry 5xx. Make them safe to retry.

Do not collapse the classes

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.

Idempotency

Two identical request darts hitting an idempotency gate that emits one commit
Timeouts duplicate requests. The gate has to remember the first commit, not hope the client will not retry.

The network will retry

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.

TypeScript · idempotency gate
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;
}
mermaid · sequence
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 instead of ad-hoc JSON

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.

JSON · application/problem+json
{
  "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"
}
Validation errors are a list, not a string

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.

Contract, Versioning, Pagination

OpenAPI is the boundary

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.

Cursor pagination
Opaque cursor over a stable sort. Survives inserts. Offset pagination (page=7) drifts under write load.
Conditional GET
ETag + If-None-Match. 304 saves payload. Weak ETags if you only need byte-change detection.
Optimistic concurrency
If-Match on PUT/PATCH. 412 when the resource moved. Better than last-write-wins for editors.
Rate limits
429 + Retry-After. Document the unit (token bucket, fixed window). Do not hide the remaining budget if you can send RateLimit headers (IETF draft / RFC 9331-adjacent practice).

The Edge Changes Your API

Cache-Control is part of the handler

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.

mermaid · flowchart
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]
Timeouts belong in the contract

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.

Key Takeaways

Principles

  1. Honor RFC 9110 method semantics. Safe and idempotent are protocol facts, not style advice.
  2. Let the status class speak. Do not bury errors in a 200 body.
  3. POST needs an idempotency key. Timeouts are retries you did not ask for.
  4. Use problem+json (RFC 9457). One error shape for every client.
  5. Version on purpose. Additive fields are cheap. Renames are a new version.
  6. Cursor-paginate write-heavy lists. Offset pages drift.
  7. Cache-Control and Vary are security controls. Shared caches will obey what you send.
  8. Publish OpenAPI and generate something from it. A wiki is not a contract.

Official Documentation