Contracts at the Boundary · Nº 07
A field guide to API design
Before you argue about status codes or pagination, you make a decision that outlives all of them: what are the things your API is about? Those things — the nouns, the resources — get stable identities and URLs that integrators will hard-code, cache, log, and build businesses on top of. You can change almost anything else later. You cannot quietly change what /cellar-entries/ce_9d2f means once a thousand clients believe it.
This module builds the running example we’ll use for the whole guide — the public API of a wine-cellar product — by finding its nouns, giving each a URL, wiring the relationships, and confronting the operations that don’t want to be nouns at all. It closes with the one positioning section this guide allows itself: when REST is the right boundary, and when GraphQL or gRPC earns its place instead.
An API is a contract you draft for parties you will never meet: integrators who build against it sight-unseen, agents that read nothing but the schema, and future-you, who will have forgotten every unwritten assumption by the time it matters. Because you never negotiate with these parties, the contract has to be self-enforcing — every behavior a caller depends on must be stated, because nothing else will state it for you.
The happy path tells you almost nothing about whether you’ve drafted well. Any endpoint returns 200 when nothing goes wrong. Quality lives in the clauses that govern trouble — what the API says when it fails, what happens when the same request arrives twice, how a list behaves while it mutates underfoot, how the promise evolves without breaking the people who relied on it. This guide is a tour of those clauses, and this module is the first: the nouns everything else hangs on.
You have drafted contracts of adhesion before. The doctrine that ambiguity is construed against the drafter applies here with almost no translation: every underspecified behavior in your API becomes an integration bug, a support ticket, or a 2am page — and you are the drafter, so you own it. ‘We never documented what happens on a duplicate POST’ is not a defense; it is an admission.
Design quality is measured on the failure path, not the happy path. For every decision, ask the three questions that carry this entire guide: what am I promising, how does it fail, and who bears the ambiguity?
Resource modeling is the work of finding your domain’s nouns, giving each a stable identity and URL, and then expressing every operation as a state change on a noun. The test is blunt: if you can’t name which noun changed, you haven’t found the resource yet. A wine-cellar product’s nouns fall out quickly — a resource for the wines in the catalog, one for the entries in a customer’s cellar, one for the record a drunk bottle leaves behind, one for a generated recommendation set, and the machinery of price alerts, webhook endpoints, and deliverable events.
Each noun gets a collection URL and a member URL under one base, https://api.winecellar.example/v1. Identity is a prefixed opaque string — wine_4t8m, ce_9d2f — so an ID is self-describing in a log line and you keep the freedom to change how IDs are generated. Here is ce_9d2f, six bottles of the 2016 Château Montrose sitting in rack B:
GET /v1/cellar-entries/ce_9d2f
200 OK
{
"id": "ce_9d2f",
"object": "cellar_entry",
"wine_id": "wine_4t8m",
"quantity": 6,
"purchase_price_usd": 172.00,
"location": "B-12",
"created_at": "2026-03-04T18:22:07Z"
}/v1. Solid edges are hard references (a cellar-entry names a wine_id; a consumption names a cellar_entry_id; an event names the alert_id and endpoint_id that produced it); dashed edges are soft references (a recommendation lists candidate wine_ids). You could reconstruct the whole surface from this figure alone.Once you have nouns, you have to let callers walk between them, and there are two ways to express a relationship in a URL. You can nest — GET /wines/wine_4t8m/cellar-entries — or you can keep collections top-level and filter — GET /cellar-entries?wine_id=wine_4t8m. They read almost identically, but they make very different promises.
Nesting bakes today’s ownership assumption into a permanent URL. /wines/{id}/cellar-entries asserts that a cellar-entry belongs to a wine and is reached through it. The moment a cellar-entry needs to be reachable another way — by rack, by customer, by purchase batch — the nested URL is either a lie or a second address for the same thing. The rule that keeps you out of trouble: identity is forever; location is a filter. Give each resource one canonical top-level identity (/cellar-entries/ce_9d2f), and express every relationship as a field on the resource and a filter on the collection. Nesting is a convenience you can add on top, never the sole address.
Every level of nesting you add (/users/{u}/cellars/{c}/racks/{r}/entries/{e}) is a compound assertion that all four ownership edges are permanent and that the caller always holds all four IDs. Move a bottle to another rack and every stored URL for it is now wrong. Keep resources one level deep and let fields carry the relationships.
Some operations refuse to be CRUD on an existing noun. Consume a bottle. Generate a fresh recommendation set. Acknowledge a price alert. The lazy resolution is to reach for a verb URL — POST /cellar-entries/ce_9d2f/consume — and now HTTP is just a transport for a procedure call. That is RPC-in-REST-clothing, and it costs you everything the uniform interface was buying: no cacheability, no shared semantics an intermediary can act on, and no natural place to hang idempotency or a status code.
The honest move is to find the action resource — the durable record the verb leaves behind. Consuming a bottle produces a consumption, so model it as one: POST /consumptions with a body naming the entry. Now the act has an identity, a timestamp, a history you can list and audit, and a clean home for an idempotency key.
POST /v1/consumptions
Idempotency-Key: 8b1e-…
{ "cellar_entry_id": "ce_9d2f", "quantity": 1, "occasion": "anniversary" }
201 Created
Location: /v1/consumptions/cons_2p4a
{
"id": "cons_2p4a",
"object": "consumption",
"cellar_entry_id": "ce_9d2f",
"quantity": 1,
"consumed_at": "2026-07-02T20:14:00Z"
}Generating a recommendation set is the same move: POST /recommendations creates a rec_ resource holding the candidate wine_ids and the inputs that produced it — auditable, cacheable, and re-fetchable, unlike a /generateRecommendations that returns a body and forgets it happened.
This guide teaches the public HTTP+JSON boundary, and it’s worth one honest section on why — and on where the alternatives belong. The decision variable is not fashion; it is who your strangers are.
REST over HTTP+JSON wins when your callers are unknown third parties. It gives you a stable, cacheable, uniformly-semantic contract that decades of tooling — proxies, browsers, CLI clients, monitoring — already understand, and a clear versioning story. GraphQL earns its place when a known, first-party client needs to shape its own reads and you’re willing to pay for query-cost governance (a public GraphQL endpoint is an open-ended compute promise to strangers). gRPC is contract-first binary RPC built for the interior: high-throughput service-to-service boundaries where both ends deploy together and you control the whole call graph.
| Dimension | REST / HTTP+JSON | GraphQL | gRPC |
|---|---|---|---|
| Best caller | Unknown third parties | Known first-party clients | Interior services (you own both ends) |
| Caching | HTTP caching, for free | Hard (POST, per-query) | Bespoke |
| Versioning story | Mature (this guide) | Field deprecation | Proto evolution rules |
| Tooling reach | Universal | Good, ecosystem-specific | Strong, polyglot, code-gen |
| Cost governance | Per-endpoint, simple | Query cost analysis required | Internal, controlled |
These are not exclusive. A perfectly good architecture runs gRPC between internal services, a GraphQL BFF for the company’s own mobile app, and a REST public API for partners — three boundaries, three strangers, three right answers. The rest of this guide is about the third.
HTTP is not your transport; it is a vocabulary that a vast amount of software you neither wrote nor control already speaks fluently. Caches, prefetchers, retrying proxies, browsers, and crawlers read your methods, status codes, and headers and act on them without ever reading your docs. This is leverage or liability depending entirely on whether you use the vocabulary precisely.
This module is the shared-vocabulary layer the rest of the guide stands on: what methods promise, what the load-bearing status codes actually claim, which headers do real work, and how conditional requests turn one fingerprint into both a caching optimization and a lost-update guard. It ends with the one brief section on who’s calling — enough of authentication to place 401 against 403, and a pointer to Guide Nº 15 for the rest.
Two properties of HTTP methods are formal, and machinery depends on them. A safe method (GET, HEAD) promises no state change — which is why a link prefetcher, a search crawler, or an antivirus URL scanner will happily fire your GETs unprompted. An idempotent method (GET, PUT, DELETE) promises that repeating the request leaves the server exactly as one send would; a retrying proxy relies on this to resend after a timeout without asking you.
Choosing a method is therefore signing a promise to software that will never read your documentation. A GET with side effects isn’t a style violation — it’s a promise broken to a crawler that will, eventually, collect on it by mutating your data while indexing. POST alone is neither safe nor idempotent, which is exactly why the consequential, non-repeatable operations live there — and why POST needs the idempotency machinery of m5 that the safe methods get for free.
Safe and idempotent are promises to intermediaries, not hints to developers. The audience for your method choice is mostly machines that never read prose — so the method is the contract term, and violating it is undefended.
The status line is the machine channel of every response — the first thing every client, cache, and proxy reads, before a single byte of body. The families are the taxonomy (2xx success, 3xx redirection/conditional, 4xx you-erred, 5xx we-erred), and within them a load-bearing dozen carry almost all the weight. Each makes a precise claim, and the discipline is to return the code whose claim is true.
| Code | The claim it makes | Wine-cellar API returns it when… |
|---|---|---|
| 200 | Success; body is the result | GET /cellar-entries/ce_9d2f succeeds |
| 201 | A resource was created; Location names it | POST /cellar-entries created ce_9d2f |
| 202 | Accepted, not yet done | POST /recommendations queued a generation job |
| 204 | Success, no body | DELETE /price-alerts/alrt_x51k succeeded |
| 304 | Your cached copy is still valid | GET with matching If-None-Match ETag |
| 400 | Malformed request; can’t even parse it | Body isn’t valid JSON |
| 401 | Who are you? (no/invalid credentials) | Missing or expired API key |
| 403 | I know you; you may not | Key valid but lacks the cellar:write scope |
| 404 | No such resource (or you may not know) | ce_ id doesn’t exist for this account |
| 409 | Conflicts with current state | Rack B is at capacity |
| 422 | Parsed fine; semantically invalid | quantity is -3 |
| 429 | Slow down (rate limited) | Over the published request budget |
| 500 / 502 / 503 | We failed / upstream failed / try later | Unhandled error; gateway down; overloaded |
Returning 200 OK with {"error": "..."} in the body lies to the machine channel. Your dashboards go green while integrations silently fail, and every client is forced to parse prose to discover whether the call worked. The symptom is a support ticket that says ‘your API says success but nothing was created.’ The corrective is one sentence: the status line, not the body, reports success or failure.
Headers are where much of the contract’s machinery lives. The working set for a well-designed API is small but load-bearing. Location after a 201 tells the client where the new resource lives (and lets it skip a round-trip). Content-Type discipline (application/json, and application/problem+json for errors — m3) keeps parsers honest. Every response carries an X-Request-Id — a request ID like req_5h1a — and every error echoes it, so a stranger’s bug report is a direct index into your logs. Retry-After and Idempotency-Key are forward references to m5; Deprecation and Sunset to m6.
Idempotency-Key makes the write safe to retry; Location saves a round-trip; X-Request-Id is the correlation token support will ask for; ETag is the concurrency fingerprint the next section spends. Each header is machinery, not garnish.One fingerprint does double duty. An ETag is a version identifier for a representation. On reads, the client sends it back as If-None-Match; if nothing changed, you answer 304 Not Modified with no body — ‘your copy is still good.’ The same fingerprint governs writes: the client sends If-Match: "e3f1", and if the resource has changed since (the ETag no longer matches), you answer 412 Precondition Failed instead of silently overwriting whoever wrote last. This is optimistic concurrency — you detect conflicts at commit time rather than locking up front.
"e3f1". A commits first and the ETag advances to "a90c". B’s If-Match "e3f1" is now stale, so the server returns 412 rather than silently discarding A’s change. B refetches, merges, and retries against the current ETag. Without the precondition, B’s write would erase A’s with no error anywhere.Two questions meet HTTP at the boundary, and they map to two status codes. Authentication — who are you? — is answered by a credential: an API key (Authorization: Bearer sk_live_…) or an OAuth2 bearer token. A missing or invalid credential is 401 Unauthorized (the name is a misnomer; it means unauthenticated). Authorization — may you? — is answered by scopes and policy: a valid key that lacks cellar:write gets 403 Forbidden. Scopes express least privilege: a read-only integration should hold a key that literally cannot write.
This is deliberately the whole treatment here: 401 vs 403 is HTTP semantics and belongs in this module. Token formats, OAuth2 flows, rotation, and agent credentials are their own discipline — Guide Nº 15 (Authentication & Identity) covers them. This module teaches only what the status vocabulary requires.
Returning 403 for another tenant’s ce_ id confirms the resource exists — an information leak. Many APIs return 404 for ‘exists but not yours’ precisely so an attacker can’t enumerate IDs by watching 403-vs-404. Pick a policy and apply it consistently; the inconsistency is the vulnerability. Quiz Q3 works this in full.
Integrators spend a handful of hours on your happy path and then weeks in your error paths — handling the malformed inputs, the conflicts, the rate limits, the outages. Your error surface is the developer experience, and yet it is the part most APIs improvise. This module treats errors as first-class: one structured envelope everywhere, stable codes clients can branch on, messages a stranger can act on at 2am, and an error catalog that ships as part of the contract.
The through-line from your legal training is exact: a contract without remedy clauses is a wish. Error design is remedy drafting — what recourse does the injured party have, and can they act on it without calling you?
The reason error design gets neglected is that it’s invisible on the demo. The endpoint works, the 200 comes back, everyone moves on. But the integrator’s real experience begins the first time something goes wrong — and from then on, the quality of your errors is the quality of your API. An error that names the problem and the remedy turns a would-be support ticket into a self-service fix; an error that says ‘something went wrong’ turns a typo into an email thread with your team.
You have drafted enough contracts to know that the operative provisions aren’t the recitals — they’re the clauses that say what happens on breach. An API is the same. The happy-path response is the recital; the error surface is the remedies section. A promise with no remedy clause is unenforceable, and an API with no error design is unusable under stress.
Every error, from every endpoint, returns the same envelope. The load-bearing field is a machine-readable error code — a stable string enum the client branches on. Alongside it: a human message, an optional errors array of per-field problems, the request_id for support, and a doc_url into the catalog. This is essentially Problem Details (RFC 9457, which obsoletes 7807) with a couple of pragmatic additions; adopt that standard or diverge from it consciously, but pick one envelope and never deviate.
422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.winecellar.example/errors/validation_failed",
"code": "validation_failed",
"message": "One or more fields are invalid.",
"errors": [
{ "field": "quantity", "issue": "must be a positive integer", "value": -3 }
],
"request_id": "req_5h1a",
"doc_url": "https://docs.winecellar.example/errors/validation_failed"
}409 Conflict
{
"code": "cellar_capacity_exceeded",
"message": "Rack B-12 holds 24 bottles and is full; move or choose another rack.",
"errors": [{ "field": "location", "issue": "rack at capacity", "value": "B-12" }],
"request_id": "req_5h1a",
"doc_url": "https://docs.winecellar.example/errors/cellar_capacity_exceeded"
}code is the only field a client should branch on; message is for the human reading logs; errors drives form-field UI; request_id connects a bug report to your logs; doc_url links to the catalog. Every error you ship fills the same shape.The difference between a useful error and a useless one is whether it names the remedy. ‘Invalid request’ tells the caller nothing they didn’t already know. The standard is three parts: what failed, why, and what to do instead. ‘cellar_entry.quantity must be a positive integer; received -3’ is a complete instruction — the integrator fixes it without reading your source, filing a ticket, or guessing.
The anti-pattern is an error that describes the failure without naming the correction. Symptom: your support inbox fills with ‘what does invalid_input mean?’ Corrective: apply the 2am test — could a stranger, with no access to your code and no one to ask, fix their request from this message alone? If not, the message is unfinished. Name the field, the constraint, and the value you received.
Individually good errors aren’t enough; integrators need to know the full set before they hit each one, so they can write exhaustive handling. The error catalog enumerates every error an endpoint can return — code, HTTP status, whether it’s retryable, and the remedy — and it ships as a contract exhibit, not tribal knowledge. The retryable column is load-bearing: it is the machine-readable input to the client’s retry logic (m5), and it’s why m3 and m5 are two halves of one clause.
| code | status | retryable | remedy |
|---|---|---|---|
| validation_failed | 422 | No (terminal) | Fix the named field(s) per errors; resend. |
| wine_not_found | 422 | No | Use a wine_id that exists; check GET /wines. |
| cellar_capacity_exceeded | 409 | No | Choose another rack or free space; resend. |
| idempotency_key_reused | 422 | No | Same key + different body is a client bug; mint a new key. (m5) |
| rate_limited | 429 | Yes, after Retry-After | Back off per header; retry with jitter. (m5) |
| internal_error | 500 | Yes, cautiously | Retry with backoff/budget; if persistent, send request_id to support. |
The idempotency_key_reused row is placed here deliberately: it appears in this catalog in m3 and is justified in m5’s worksheet, so the two modules describe one consistent endpoint.
Validation deserves its own discipline. Return 422 with an errors array in one consistent path notation, and collect all problems in one pass rather than failing on the first — the integrator’s iteration time is a cost you control, and one round-trip that surfaces every error beats five that reveal them one at a time. Then decide what belongs in diagnostic detail versus what leaks internals.
A stack trace, a SQL fragment, or an internal hostname in an error body is an information-disclosure vulnerability, not a debugging convenience. The safe pattern: put the diagnostic detail behind the request_id in your logs, and give the client only the remedy. Detail for you; remedy for them.
Pagination is where a lot of engineers first meet a distributed-systems problem without recognizing it. Slicing a static list is trivial; the trouble is that real collections mutate while a client is walking them. Rows get inserted and deleted between page fetches, and a naïve scheme quietly serves some rows twice and skips others entirely — silently, unreproducibly, and sometimes in a way that turns a UX nicety into a compliance finding.
This module treats pagination as the consistency problem it is: the two failure modes, exactly how offset breaks under insertion, how a keyset cursor holds a page stable, and what you can honestly promise a client walking your collection.
The moment a collection can change between page requests, iterating it is a consistency question wearing a UX costume. There are exactly two failure modes: a client can skip an item it should have seen, or see an item twice. Which one you can tolerate is a property of the use case, not a universal answer.
A billing export that feeds a financial reconciliation tolerates neither — a skipped transaction is a missing record and a duplicate is a double count. An infinite-scroll feed tolerates both — a user who sees one post twice or misses one while new posts arrive upstream will never notice. Design pagination by first asking which failure mode your caller can survive, because that answer chooses the mechanism.
Pagination’s hard part is not page size; it’s iteration correctness under concurrent mutation. The two failure modes — skip and duplicate — are the whole design space, and completeness-critical callers can tolerate neither.
Offset pagination — LIMIT 20 OFFSET 40 — addresses a page by its position in an ordered list. Position is relative to a moving target: insert one row before the client’s cursor and every subsequent row shifts by one, so the next page re-serves a row the client already saw (the one that slid down into the new window) and skips one that slid past. Delete a row and the mirror happens. There’s also a performance cliff: OFFSET 100000 makes the database count and discard a hundred thousand rows to return twenty.
(ts, id=D) asks for rows strictly after D — NEW sorts before D so it’s correctly outside this walk, and every row present the whole time is seen exactly once.Symptom: integrators report records missing from, or duplicated in, exports — and can’t reproduce it, because it only happens when a write lands between two page fetches. The corrective is a keyset cursor for any completeness-critical walk. Offset’s bug is invisible in every test with a static fixture.
A keyset cursor replaces ‘position’ with ‘a point in a stable total order.’ You sort by a key that only moves forward — created_at — plus a unique tiebreaker — id — so that (created_at, id) is a strict total order with no ambiguous ties. The cursor encodes that pair; the next page is WHERE (created_at, id) > (:ts, :id) ORDER BY created_at, id LIMIT n. Rows inserted elsewhere don’t shift the anchor, because the anchor is a value, not a count.
The token is opaque — base64 of the keyset — and that opacity is a contract freedom, not decoration. Because clients can’t read or construct it, you keep the right to change the encoding, add a shard key, or switch storage engines later. The response envelope is small and honest: data, next_cursor (null at the end), has_more.
GET /v1/cellar-entries?limit=3
200 OK
{
"data": [ { "id": "ce_9d2f", … }, …3 items… ],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wMy0wNFQxODoyMjowN1oiLCJpZCI6ImNlXzlkMmYifQ",
"has_more": true
}
// that cursor decodes to: {"created_at":"2026-03-04T18:22:07Z","id":"ce_9d2f"}
GET /v1/cellar-entries?limit=3&cursor=eyJjcmVhdGVkX2F0…
// → WHERE (created_at,id) > ('2026-03-04T18:22:07Z','ce_9d2f') ORDER BY created_at,id LIMIT 3(created_at, id), anchors between two rows of the total order, and compiles to a WHERE (created_at,id) > (…) predicate. Because it names a value the anchor doesn’t move when rows are inserted elsewhere — and because it’s opaque, you can change all of this later.A cursor has a contract you must state honestly. Invalidation: what if the anchored row is deleted? With a keyset cursor, nothing breaks — (created_at, id) > (:ts, :id) still orders correctly even if that exact row is gone, which is precisely why keyset beats ‘cursor = the last row’s URL’ (that scheme dead-ends when the row disappears). Binding: the sort and filters are baked into the token; reusing a cursor with a different filter or sort is incoherent, so reject it with 400 cursor_invalid rather than returning nonsense. The honest guarantee is two sentences you can put in your docs and defend in an incident: ‘Items present throughout the iteration are returned exactly once. Items created after the iteration began may or may not appear.’
If clients can decode your cursor, some will construct their own to ‘jump ahead,’ and then your encoding is a de-facto public API you can never change (Hyrum’s law). Enforce opacity by rejecting fabricated or tampered tokens — it protects the client’s integration as much as your freedom.
Every client that talks to your API over a network will eventually send a request, do the work of waiting, and get nothing back — a timeout, a dropped connection, a 502 from an intermediary. The client now faces the oldest question in distributed systems: did the server do the thing? For a GET it doesn’t matter. For a mutation it matters enormously, and the client’s only rational move — retry — is exactly the move that duplicates the side effect. Idempotency keys resolve this standoff.
This module covers both halves of the retry clause: the server’s side (the key mechanism, and rate limiting as self-defense) and the client’s side (backoff etiquette, respect for Retry-After), and it ends with the exact guarantee you can honestly print in your docs — ‘exactly-once’ as an illusion assembled from at-least-once delivery plus at-most-once execution.
HTTP gives some methods a free pass: idempotent methods (GET, PUT, DELETE) are defined so that repeating the request leaves the server in the same state as sending it once. POST has no such promise — each send may create another resource, move more money, fire another email. Yet POST is where your most consequential operations live.
The failure that matters is not ‘the request never arrived.’ It’s the ambiguous one: the server received the request, performed the side effect, and the response was lost. From the client’s chair these are indistinguishable — both look like a timeout. A client that never retries will drop real work on transient noise; a client that retries blindly will duplicate side effects. Neither is acceptable, and no amount of cleverness on the client alone can fix it, because the information that distinguishes the two cases exists only on the server.
Ambiguity after a timeout is physics, not a bug. The only fix is to make the retry safe rather than trying to make the failure impossible — which means the server must be able to recognize a resend of a request it has already executed.
The client generates a unique key — a UUID is fine — and sends it on the request: Idempotency-Key: 7d2c9a41-…. The server treats the key, scoped to the authenticated caller, as the identity of the operation rather than the HTTP request. First time it sees the key: execute, then store the key with the response (status and body) before releasing it. Any later request bearing the same key: skip execution entirely and replay the stored response, byte for byte.
Three design decisions do most of the work. Where the key lives: store it transactionally with the side effect — if the charge commits but the key doesn’t, you’ve rebuilt the race you were trying to remove. Scope: keys are per-caller; two tenants sending the same UUID must not collide. Retention: keys expire — 24 hours covers real retry behavior without unbounded storage, and the window belongs in your docs because it is part of the contract.
An idempotency key deduplicates sends of one operation; it does not deduplicate intent. If the user clicks ‘Pay’ twice, the client mints two keys and both charges are ‘correct.’ Guarding against duplicate intent is a product decision (disable the button, confirm the second attempt) — don’t ask the API layer to read minds.
Idempotency makes retries safe; it does not make them polite. A well-behaved client owes four things, and a good API documents them as the client’s side of the contract. First, retry only what the catalog marks retryable (m3) — a 422 is terminal; retrying it is pointless load. Second, backoff with jitter: exponential spacing that is also randomized, because synchronized retries are how one blip becomes two outages. Third, a retry budget — a cap so a client can’t retry forever. Fourth, obedience to Retry-After: when the server tells you when to come back, coming back sooner is abuse.
The decision rule the guide commits to, by status: 429 / 503 / timeout → retry with backoff and jitter; 500 / 502 → retry cautiously, within budget; any other 4xx → terminal, fix the request, do not retry.
Exponential backoff without jitter still synchronizes: every client that failed at the same instant retries at the same computed instant. A deploy blip at 14:02:00 becomes a thundering herd at 14:02:30 that knocks the just-recovered service back down. Jitter — randomizing each delay within its window — is the desynchronizer, and it is not optional.
Rate limiting is the server’s half of the same clause. The usual mechanism is a token bucket: a bucket of capacity C refills at rate R; each request spends a token; a burst can drain the bucket (absorbing spikes up to C) but sustained throughput is capped at R. That two-parameter shape — burst tolerance plus sustained rate — is usually what you actually mean, where a fixed window is crude and a sliding window is more complex for little gain.
Crucially, limits are published contract terms, not surprises. A client that hits the limit should get 429 with Retry-After and a RateLimit-* header family it can engineer against — never a naked 429. Scope the limit per API key or per tenant so one noisy client can’t starve another.
Retry-After and RateLimit-* headers — machine-readable terms the client can back off against, not a naked rejection.On your GRC turf, rate limiting is two controls at once: an availability control (one tenant can’t exhaust capacity for the rest) and an abuse/scraping control. ‘We rate-limit per key with published limits’ belongs in the same evidence locker as ‘we log access’ — it’s a control you can point an auditor to, not just an engineering nicety.
Now assemble the guarantee. Over an unreliable network the only honest delivery promise is at-least-once delivery: a request may arrive many times. The idempotency key adds at-most-once execution per key. Compose the two and the client observes a result that looks like the operation happened exactly once — but note what that is: the exactly-once illusion is engineered, not a physical property of the network.
This matters because of what you write in your docs. ‘We guarantee exactly-once delivery’ is a claim you cannot defend in an incident review — delivery is at-least-once and always will be. The defensible version is the decomposition: ‘Send a request as many times as you need with the same idempotency key; the operation executes at most once, and you will eventually observe its stored result.’ That sentence is true, mechanically backed, and survives the post-mortem.
An API that never changed would be easy and useless. Every real API grows, and the moment it does it faces its hardest test: evolving the promise without breaking the parties who relied on it. This is where most of the trust in an API is won or lost, because integrators judge you not by your first release but by whether your tenth one broke them.
This module defines breaking precisely (it’s a property of the contract, not your intent), compares versioning strategies and commits to one, lays out a deprecation protocol you can execute end-to-end, and lands on OpenAPI as the artifact that makes all of it mechanically checkable — including the payoff that a breaking change can be caught in CI before it ships.
A breaking change is any change that can make a well-behaved existing client misbehave — and ‘well-behaved’ is defined by the contract, not by what you wish clients did. Removing a field, renaming it, retyping it, or tightening validation all break. Adding an optional field or a new endpoint is additive — but only if the contract obligated clients to be a tolerant reader: ‘clients must ignore unknown fields’ is a clause you must have written, not a hope you get to hold after the fact.
The sharp case is enums. Adding status: "reserved" to a field looks additive, but it breaks every client whose code does an exhaustive switch over the known values — unless you had promised that clients must tolerate unknown enum values too. Whether the change is breaking depends on what was promised, which is the lawyer’s answer and the correct one.
| Change | Breaking? | The clause that decides it |
|---|---|---|
| Remove/rename a field | Yes | Always — the field was in the contract. |
| Retype a field (int→string) | Yes | Always — parsers are typed. |
| Tighten validation (new required field) | Yes | Previously-valid requests now fail. |
| Add an optional response field | No* | *Only if ‘ignore unknown fields’ was promised. |
| Add a new enum value | Depends | Additive iff ‘tolerate unknown enum values’ was promised. |
| Add a new endpoint | No | Existing clients don’t call it. |
Symptom: ‘it’s just a rename, the SDK handles it, ship it as v1.1.’ The wire contract, not your SDK version, is the promise. A partner integrating over raw HTTP never saw your SDK, and a rename is a breaking change no matter what number you print next to it. SemVer describes the contract’s compatibility, it doesn’t grant it.
Four strategies show up in the wild. URL major versions (/v1/) are the most visible and cache-friendly. Header-based (Accept: application/vnd.winecellar.v2+json) keeps URLs clean but is invisible in a browser and easy to forget. Query param (?version=2) is simple but pollutes caching and gets dropped in copy-paste. Date-pinned versions (Stripe-style, pinned per account) give the smoothest client experience at the cost of real internal machinery — every version transformation maintained forever.
| Strategy | Caching | Discoverability | Gateway routing | SDK ergonomics | Docs burden |
|---|---|---|---|---|---|
| URL /v1/ | Clean (path-keyed) | High (visible) | Trivial (path match) | Good | Low |
| Header | Needs Vary | Low (hidden) | Header inspection | Good | Medium |
| Query param | Poor (cache splits) | Medium | Query parsing | Fair | Medium |
| Date-pinned | Clean | Low | Per-account lookup | Excellent | High (all versions live) |
Use URL major versions plus strict additive discipline within a version. You bump /v1/→/v2/ only for genuinely breaking redesigns, and inside a version you only ever add. Date-pinning is a real technique but it earns its considerable complexity only at very high change volume — most APIs that adopt it are paying Stripe’s costs without Stripe’s change rate.
Deprecation is a notice obligation, and it has a shape. Announce: changelog entry, Deprecation and Sunset headers on the old version’s responses (the Sunset header, RFC 8594, is the machine-readable end-of-life date), and direct contact for callers you can identify. Dual-run: serve old and new simultaneously. Monitor adoption: watch the migration curve, gated at checkpoints. Escalate: reach holdouts directly. Brownout: scheduled temporary 410 windows that surface inattentive integrators while stakes are low. Sunset: the old version returns 410 with a pointer to the migration doc.
From your contracts training: integrators rely on your endpoints to their detriment, so the notice period must be published before you need it. ‘We gave notice’ is necessary but not sufficient — a sunset executed while 30% of traffic still uses v1 is a discharge you’ll defend in a very unhappy support thread.
location string vs a structured {rack, slot}) lives entirely in the adapters. Dual-run is not a fork — forking doubles your maintenance and drifts; adapters keep one source of truth.All of this needs a source of truth, and it should be the spec, not the code. OpenAPI — written spec-first — is the single artifact from which docs, SDKs, and mock servers are generated, and, the load-bearing payoff, from which breaking changes are detected mechanically: diff the spec in CI and a breaking change cannot merge unlabeled. Contract tests are the enforcement teeth: they assert that the running server actually matches the spec, so the spec can’t quietly drift from reality.
# openapi.yaml (excerpt)
openapi: 3.1.0
info: { title: Wine Cellar API, version: "1.4.0" }
paths:
/cellar-entries:
post:
operationId: createCellarEntry
responses:
"201": { $ref: "#/components/responses/CellarEntry" }
"409": { $ref: "#/components/responses/Error" } # cellar_capacity_exceeded
"422": { $ref: "#/components/responses/Error" } # validation_failed, idempotency_key_reused
"429": { $ref: "#/components/responses/Error" } # rate_limited
components:
schemas:
CellarEntry:
type: object
required: [id, wine_id, quantity]
properties:
id: { type: string, example: "ce_9d2f" }
wine_id: { type: string, example: "wine_4t8m" }
quantity: { type: integer, minimum: 1, example: 6 }
location: { type: string, example: "B-12" }
Error:
type: object
required: [code, message, request_id]
properties:
code: { type: string, example: "validation_failed" }
message: { type: string }
request_id: { type: string, example: "req_5h1a" }The error catalog from m3 is wired directly into the spec — every documented status $refs the shared Error shape and is annotated with the codes it can carry. The catalog and the spec are the same artifact seen from two angles.
So far you’ve been the server: strangers call you, and you set the terms. A webhook flips the board. Now you initiate the call, to an endpoint a stranger operates, over the same unreliable network — and every clause you imposed on your callers (retry safely, dedupe, back off, authenticate) becomes an obligation you owe them. Most webhook systems are bad because their authors forgot they’d become the client.
This module builds the event side of the wine-cellar API — the price_alert.triggered webhook — with honest delivery semantics, a visible delivery lifecycle, signing with replay protection, and the consumer ergonomics that separate an integrable event API from a support burden.
A webhook is an outbound HTTP request you make to a URL the consumer registered — whk_c907 — when something happens. The reversal is the whole lesson: you are now the client, and an unreliable one, calling a server you don’t control and whose availability you can’t assume. Every asymmetry you enjoyed as the server is now working against you.
Webhooks beat polling on latency (the consumer learns immediately, not on the next poll) and cost (no armies of clients hammering GET on a mostly-unchanged resource). But the design that beats both is the hybrid: the webhook is an invalidation signal — ‘something about alrt_x51k changed’ — and a GET remains the source of truth the consumer fetches to get authoritative state.
The retry dilemma from m5 is back, with you on the sending side. When your delivery times out, you don’t know if the consumer processed it — so you retry, and now you are the one who might cause a duplicate. Everything m5 asked of your callers, you now owe your consumers.
Be honest about what you can promise, because consumers will build on it. The only defensible delivery guarantee is at-least-once: a timeout after the consumer already processed the event forces you to redeliver, so duplicates are inevitable. And ordering cannot be promised either — retries reorder events by construction, so an entry.updated can arrive before a retried entry.created.
These aren’t caveats to bury; they’re clauses the consumer must design against. Dedupe by the event id (evt_7g3p). Treat events as hints, not state transfers. Design thin events — ‘ce_9d2f changed; fetch it’ — so that disorder is harmless: if the consumer always fetches current state on any event, the order the events arrived in stops mattering.
At-least-once and unordered are not failures of your webhook system; they are the physics of delivery over an unreliable network. A thin event plus a consumer that fetches current state converts both from hazards into non-events.
Your obligations as the sender are best made visible as a state machine, because each state is a promise to the consumer. A delivery is created, moves to delivering, and on a 2xx becomes delivered. On failure it enters retrying with a backoff schedule, and after the schedule is exhausted it is dead-lettered — not dropped. A dead letter must be visible and replayable, never a silent grave. If an endpoint fails persistently you may auto-disable it, but with notification — silently disabling an endpoint is data loss you inflicted on the consumer.
An unsigned webhook endpoint is an unauthenticated write API into the consumer’s business logic — anyone who learns the URL can POST a forged ‘payment succeeded’ event. Sign every delivery. Compute an HMAC over timestamp + "." + payload with a shared secret and send it as Webhook-Signature: t=1783007640,v1=5257a8…. The consumer verifies in a fixed order: recompute the HMAC over the received timestamp and raw body, compare in constant time, and check the timestamp is within a tolerance window (say ±5 minutes).
The timestamp is in the signed material for a reason: it defeats a replay attack. An attacker who captures a valid signed request can resend it byte-for-byte — the signature is still valid — but the stale timestamp falls outside the tolerance window, so the consumer rejects it. Rotate secrets without dropping events using dual secrets: sign with the new secret, accept either during the overlap, then retire the old one.
This is the reader’s security domain squarely: authenticating unsolicited inbound requests, with the tolerance window as the tunable replay-defense parameter. A tight window is safer but less forgiving of clock skew; state the window in the docs so consumers can sync clocks and size their own tolerance.
Delivery semantics keep the consumer’s data correct; ergonomics decide whether they can integrate at all without emailing you. The integrable event API ships: an event catalog with schemas (the events half of the OpenAPI/AsyncAPI artifact — the m6 bridge), sandbox trigger endpoints (POST /test/events so a consumer can fire a price_alert.triggered on demand instead of waiting for a real price drop), consumer-visible delivery logs with response codes, a redelivery control, and a clearly documented payload shape.
POST https://consumer.example/hooks (your delivery to whk_c907)
Webhook-Signature: t=1783007640,v1=5257a8b3c1…
Content-Type: application/json
{
"id": "evt_7g3p",
"type": "price_alert.triggered",
"created": "2026-07-02T15:54:00Z",
"data": { "alert_id": "alrt_x51k", "wine_id": "wine_4t8m", "threshold_usd": 180, "observed_usd": 174 }
}
// thin: enough to act, but the consumer GETs /price-alerts/alrt_x51k for authoritative stateA new class of integrator has arrived, and it doesn’t read your blog, file support tickets, or carry institutional memory between sessions. An LLM tool-caller reads your schema and your response and nothing else. That single property inverts several instincts you’ve built over the last seven modules — and, usefully, it makes the rest of the guide pay off, because an agent that paginates your cursors, retries your mutations, and consumes your rate limits is just a very literal, very tireless version of every stranger you’ve been designing for.
This module is the provider side of the machine-caller boundary: intent-shaped schemas, error bodies verbose enough for an agent to self-correct, and documentation that collapses into the spec — and it closes the guide with the consolidated anti-pattern catalog and the single question that runs through all of it.
Characterize the caller precisely, because the design follows from the properties. An LLM tool-caller: reads the schema and the response and nothing else (no docs site, no Stack Overflow, no memory of last week’s integration call); retries tirelessly and cheaply; takes your descriptions literally; and — the useful inversion — can recover from prose, acting on a well-written error message the way a human would, but automatically and immediately.
The design consequence is one sentence: everything the caller needs must be in-band. There is no second channel — no ‘see the docs,’ no ‘email us,’ no tribal knowledge. If it isn’t in the schema or the response, it does not exist for this caller.
Guide Nº 02’s tool-calling module taught the consumer side of this boundary — how to build the agent loop that calls tools. This module is the provider side: you are now designing the tools that someone else’s agent loop will call, and the two guides meet at the schema.
The tempting shortcut is to auto-export your REST surface as tools: forty endpoints become forty tools. That produces endpoint-mirror tool schemas — they mirror your storage model, expose internal IDs, and force the agent to chain calls to accomplish one intent. The corrective is intent-shaped tools: fewer, task-oriented, with constrained types (enums over free strings), defaults stated in the schema, and descriptions written as the documentation they now are.
Here is the mandated before/after, part one — wine search:
search_wines takes what a caller actually has — a query, a pairing, a budget, an enum’d region — and returns what they want. Fewer parameters, constrained types, described defaults: the agent succeeds on the first call.Symptom: an agent picks the wrong tool from forty near-identical ones, or chains three calls to do one task, or passes an internal ID it hallucinated. Corrective: curate a small set of intent-shaped tools with enum’d parameters and descriptions-as-docs; the tools model caller intent, not your tables.
Human APIs keep errors terse to avoid clutter. For agents the rule inverts: because an agent acts on prose and has no other channel, your machine-caller errors should be the most verbose surface you ship — what failed, why, and exactly what to try instead, all in-band. This is m3’s remedy principle with a reader that executes the remedy autonomously.
The mandated before/after, part two — the same invalid-region failure:
// terse (human-API instinct) — the agent hallucinates a fix and loops
422 { "code": "validation_failed", "message": "Invalid region." }
// verbose (machine-caller) — the agent self-corrects on the next call
422 {
"code": "invalid_enum_value",
"message": "No wines match region 'Borduex'. Did you mean 'Bordeaux'? Valid regions: Bordeaux, Burgundy, Rioja, Napa, Tuscany, Mosel.",
"field": "region",
"received": "Borduex",
"closest_valid": "Bordeaux",
"allowed": ["Bordeaux","Burgundy","Rioja","Napa","Tuscany","Mosel"],
"retryable": false
}closest_valid and allowed is a machine-actionable remedy: the agent’s next call succeeds. Retryability flags matter more here, not less, because agents loop on ambiguity.For this caller the documentation channel collapses into the contract artifact. The OpenAPI spec (m6) generates the tool definitions — MCP servers are built from specs — so your schema descriptions become the prompt the model reads, and examples in the spec become few-shot demonstrations. The prose you’d have put in a docs site now has to live in the description fields, because that’s the only text the agent sees.
And here the whole guide re-converges. Agents paginate your cursors (m4) — so an ambiguous or offset-broken pagination becomes an agent that misses rows silently. Agents retry your mutations (m5) — so a mutating tool without an idempotency key is a duplicate generator wired to a tireless caller. Agents consume your rate limits in parallel (m5) — so your RateLimit-* headers are exactly what the agent’s backoff logic reads. Everything you built for human strangers is what makes you safe for machine ones.
You do not need a separate ‘AI API.’ A well-drafted contract — intent-shaped, honestly-erroring, idempotent, cursor-paginated, rate-limited with headers — is already most of what an agent needs. The machine caller rewards the same discipline the human one did, and punishes its absence faster.
Consolidated as the guide’s field reference — each anti-pattern as symptom → diagnosis → corrective, cross-referenced to the module that teaches it.
| Anti-pattern | Symptom you’ll observe | Corrective | Module |
|---|---|---|---|
| RPC-in-REST-clothing | URLs full of camelCase verbs (/createEntry) | The noun test; model state changes on resources | m1 |
| 200-with-error-body | Dashboards green while integrations fail | Status line is the machine channel | m2 |
| Error-without-remedy | Support inbox: ‘what does this error mean?’ | Name field, constraint, value; 2am test | m3 |
| Offset-on-mutable-collections | Exports miss/duplicate rows, unreproducibly | Keyset cursor for complete iteration | m4 |
| Retry-without-jitter | Synchronized retry wave re-downs a recovery | Exponential backoff with jitter + budget | m5 |
| Unpublished rate limits | Clients guess sleep intervals against naked 429s | Publish limits; Retry-After + RateLimit-* | m5 |
| Breaking-as-minor | Raw-HTTP partner breaks on a ‘minor’ bump | Wire contract is the promise; new major | m6 |
| Unsigned-webhooks | Forged events accepted by a consumer | HMAC over timestamp+payload; window | m7 |
| Silent auto-disable | A week of missing events found in an escalation | Notify on disable; delivery logs; replay | m7 |
| Endpoint-mirror tool schemas | Agent picks wrong tool / chains three calls | Intent-shaped tools; enums; descriptions | m8 |
Return to where the guide began. An API is a promise made to strangers — human integrators you’ll never meet, and machine callers that read nothing but the contract. You’ve now walked the clauses: the nouns, the shared vocabulary of HTTP, the error surface, pagination under mutation, safe retries, honest change, the roles reversed in webhooks, and the newest caller who can read only what’s in-band.
The habit that outlasts every specific technique is a single question, asked of every design decision: what am I promising, how does it fail, and who bears the ambiguity? Ask it and you will design well even in the corners of the discipline this guide didn’t cover, because the question doesn’t change. The unglamorous clauses are where integrators — of both kinds — learn whether to trust you, and the newest integrators can read nothing else.
This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.