Contracts at the Boundary · Nº 07

Contracts at the Boundary

A field guide to API design

Any endpoint can return 200 when nothing goes wrong — the happy path carries no information about quality. Trust is earned in the unglamorous clauses: errors that name the remedy, retries that cannot double-charge, pages that hold still while the data moves, versions that sunset without burning anyone. This guide teaches those clauses — for the human integrators you will never meet, and for the machine callers that read nothing but the contract.

Module 01 Resources: nouns before verbs

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.

A promise made to strangers

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.

Cross-reference — contra proferentem

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.

The load-bearing idea

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?

Nouns, not verbs

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"
}
Resource-relationship map of the wine-cellar API: each resource shown as a box with its URL, edges labeled with the reference field that connects themwines/wines/{id}cellar-entries/cellar-entries/{id}consumptions/consumptions/{id}wine_idcellar_entry_idrecommendations/recommendations/{id}wine_ids[]price-alerts/price-alerts/{id}events/events/{id}webhook-endpoints/webhook-endpoints/{id}alert_idendpoint_idwine_id
Figure 1.1 — The resource map is the URL map. Seven nouns, each with a collection and member URL under /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.

Relationships and traversal

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 nestGET /wines/wine_4t8m/cellar-entries — or you can keep collections top-level and filterGET /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.

When it misleads — deep nesting

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.

Actions that don’t fit

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"
}
Decision flowchart for modeling an operation that resists CRUD: is it CRUD on an existing noun, does it produce a durable record, or is it a state transition with its own lifecycleAn operation arrivesCRUD on an existing noun?Use GET/PUT/PATCH/DELETE on ityesProduces a durable record?noPOST to create thatrecord (a consumption)yesA lifecycle state change?noPATCH a status field, orPOST a sub-resourceyesDon’t invent/doThing — rethinkno
Figure 1.2 — The noun test as a flowchart. An operation that isn’t CRUD is almost always the creation of a durable record or a lifecycle transition. Only when all three exits fail should you pause — and the answer is never a verb URL; it’s that you haven’t found the noun.
Note

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.

Where REST sits

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.

DimensionREST / HTTP+JSONGraphQLgRPC
Best callerUnknown third partiesKnown first-party clientsInterior services (you own both ends)
CachingHTTP caching, for freeHard (POST, per-query)Bespoke
Versioning storyMature (this guide)Field deprecationProto evolution rules
Tooling reachUniversalGood, ecosystem-specificStrong, polyglot, code-gen
Cost governancePer-endpoint, simpleQuery cost analysis requiredInternal, controlled
Note

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.

Module 02 HTTP as shared vocabulary

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.

Methods are promises

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.

The load-bearing idea

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.

Status codes: the first word of every answer

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.

CodeThe claim it makesWine-cellar API returns it when…
200Success; body is the resultGET /cellar-entries/ce_9d2f succeeds
201A resource was created; Location names itPOST /cellar-entries created ce_9d2f
202Accepted, not yet donePOST /recommendations queued a generation job
204Success, no bodyDELETE /price-alerts/alrt_x51k succeeded
304Your cached copy is still validGET with matching If-None-Match ETag
400Malformed request; can’t even parse itBody isn’t valid JSON
401Who are you? (no/invalid credentials)Missing or expired API key
403I know you; you may notKey valid but lacks the cellar:write scope
404No such resource (or you may not know)ce_ id doesn’t exist for this account
409Conflicts with current stateRack B is at capacity
422Parsed fine; semantically invalidquantity is -3
429Slow down (rate limited)Over the published request budget
500 / 502 / 503We failed / upstream failed / try laterUnhandled error; gateway down; overloaded
When it misleads — 200-with-error-body

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 that matter

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.

Anatomy of a POST /cellar-entries request and its 201 response, with each significant header annotated by what relies on itRequestResponsePOST /v1/cellar-entriesAuthorization: Bearer sk_…Content-Type: application/jsonIdempotency-Key: 8b1e-…Accept: application/json{ "wine_id": "wine_4t8m", "quantity": 6, "location": "B-12" }201 CreatedLocation: /v1/cellar-entries/ce_9d2fContent-Type: application/jsonX-Request-Id: req_5h1aETag: "e3f1"{ "id": "ce_9d2f", "wine_id": "wine_4t8m", "quantity": 6 }↑ makes the retry safe (m5)↑ client skips a GET to find it↑ echoed by every error → your logs↑ concurrency fingerprint (next section)Every significant header is a contract term somemachine relies on — not decoration.
Figure 2.1 — A request/response as a pile of promises. The 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.

Caching and concurrency semantics

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.

Swimlane sequence: two clients editing the same cellar entry concurrently; client B’s If-Match on a stale ETag is rejected with 412 instead of silently overwriting client AClient AAPI serverClient BGET ce_9d2f → ETag "e3f1"GET ce_9d2f → ETag "e3f1"PATCH If-Match "e3f1" → OK, new ETag "a90c"PATCH If-Match "e3f1" (stale)412 Precondition Failedrefetch → merge → retryPATCH If-Match "a90c" → OK
Figure 2.2 — The lost update, caught. Both clients read ce_9d2f at ETag "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.

Who’s calling: authN/Z at the boundary (brief)

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.

Cross-reference — Guide Nº 15

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.

When it misleads — existence disclosure

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.

Module 03 Failing honestly: error design

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?

Errors are a first-class surface

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.

Cross-reference — remedy clauses

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.

The structured error body

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"
}
Anatomy of the structured error body: each field annotated with who consumes it — code for client branch logic, message for the developer, request_id for support, doc_url for the catalog{"code": "validation_failed","message": "…must be positive…","errors": [ { field, issue } ],"request_id": "req_5h1a","doc_url": "…/validation_failed"}client branch logicthe human developerwhich field, for a formyour support desk → logsthe error catalog
Figure 3.1 — One envelope, four audiences. 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.

Actionable messages: name the remedy

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.

When it misleads — error-without-remedy

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.

The error catalog as documentation

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.

codestatusretryableremedy
validation_failed422No (terminal)Fix the named field(s) per errors; resend.
wine_not_found422NoUse a wine_id that exists; check GET /wines.
cellar_capacity_exceeded409NoChoose another rack or free space; resend.
idempotency_key_reused422NoSame key + different body is a client bug; mint a new key. (m5)
rate_limited429Yes, after Retry-AfterBack off per header; retry with jitter. (m5)
internal_error500Yes, cautiouslyRetry with backoff/budget; if persistent, send request_id to support.
Note

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 errors done right

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.

Decision flowchart for choosing the correct 4xx status: malformed to 400, unauthenticated to 401, forbidden to 403, missing to 404, state conflict to 409, semantically invalid to 422, rate limited to 429A request failsCan’t even parse it?400No/invalid credential?401Known, but not allowed?403No such resource?404Conflicts with state?409Parsed, but invalid?422Too many requests?429Check top to bottom; the first true row wins. 422 is thecatch-all for ‘understood but semantically wrong’, not 400.
Figure 3.2 — Choosing the 4xx. Walk the questions in order; the first ‘yes’ is your status. The common mistake is collapsing 422 into 400 — 400 is ‘I couldn’t parse this’, 422 is ‘I parsed it and it’s wrong’, and the distinction tells the client whether re-encoding or re-thinking is the fix.
When it misleads — internal leakage

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.

Module 04 Pagination under mutation

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.

Pagination is a consistency problem

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.

The load-bearing idea

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 and how it breaks

Offset paginationLIMIT 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.

Two-panel comparison: the same collection with a row inserted between page fetches; offset pagination re-serves one row and skips another, cursor pagination anchored to the keyset is unaffectedOffset — LIMIT 4 OFFSET 4Cursor — after id=Dpage 1 read: A B C Dthen NEW inserted before D:A B C NEW D E F …page 2 = OFFSET 4 →D E F ← D repeatsD served twice; and had a row beendeleted, one would vanish unseen.page 1 read: A B C Dnext_cursor encodes (ts, id=D)A B C NEW D E F …page 2 = WHERE (ts,id) > D →E F … ← NEW sorts before D, skippedevery row present throughout isseen exactly once, insert or not.
Figure 4.1 — Same insert, two outcomes. A page-1 read returns A–D; a row NEW is inserted ahead of D; the client asks for page 2. Offset (position 4) re-serves D and would skip a row on deletion. The keyset cursor anchored at (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.
When it misleads — offset-on-mutable-collections

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.

Cursor mechanics

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
Cursor anatomy: an opaque base64 token decoded into its created_at and id keyset, anchored between two rows of an ordered list, with the WHERE clause it compiles tocursor = eyJjcmVhdGVkX2F0Ijoi… (opaque)base64 decode ↓{ created_at: 2026-03-04T18:22:07Z, id: ce_9d2f }ordered listce_1a… 18:20ce_9d2f 18:22 ◂ anchorce_c4… 18:25 ← nextce_e7… 18:29WHERE (created_at, id) > (:ts, :id) ORDER BY created_at, id
Figure 4.2 — A cursor is a value, not a position. The opaque token decodes to (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.

The contract surface of a page

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.’

When it misleads — transparent cursors

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.

Module 05 Idempotency and safe retries

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.

The retry dilemma

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.

The load-bearing idea

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 idempotency key mechanism

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.

Sequence diagram: a payment request whose response is lost, safely retried with the same idempotency keyClientAPI serverKey storePOST /charges · key: 7d2ckey 7d2c unseen → execute chargestore key + response (201)201 response lost in transit ✕timeout — retryPOST /charges · key: 7d2c (same)key 7d2c seen → skip executionreplay stored 201 — one charge total
Figure 5.1 — The duplicate suppressed. The first attempt executes and stores its response under the key before replying; the response is lost; the retry carries the same key, so the server skips execution and replays the stored 201. The customer is charged exactly once even though the request was sent twice.

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.

When it misleads

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.

Retry etiquette: the client’s half of the clause

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.

When it misleads — retry without jitter

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: the server’s self-defense

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.

Token-bucket rate limiting: a bucket with capacity and refill rate, a burst of requests draining it, accepted requests in green and an over-budget request bounced with 429 and Retry-Aftercapacity C = 104 leftrefill R = 5/secreq 1–6 → 200 (spend)burst absorbed to Creq 11 → 429 (bucket empty)429 Too Many RequestsRetry-After: 2RateLimit-Limit: 300 RateLimit-Remaining: 0
Figure 5.2 — Burst absorbed, sustained rate capped. The bucket (capacity 10, refill 5/sec) lets a short burst through, then bounces the over-budget request with a 429 that carries Retry-After and RateLimit-* headers — machine-readable terms the client can back off against, not a naked rejection.
Cross-reference — GRC

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.

What you can honestly promise

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.

Module 06 Versioning and deprecation

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.

What counts as breaking

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.

ChangeBreaking?The clause that decides it
Remove/rename a fieldYesAlways — the field was in the contract.
Retype a field (int→string)YesAlways — parsers are typed.
Tighten validation (new required field)YesPreviously-valid requests now fail.
Add an optional response fieldNo**Only if ‘ignore unknown fields’ was promised.
Add a new enum valueDependsAdditive iff ‘tolerate unknown enum values’ was promised.
Add a new endpointNoExisting clients don’t call it.
When it misleads — breaking-as-minor

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.

Versioning strategies compared

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.

StrategyCachingDiscoverabilityGateway routingSDK ergonomicsDocs burden
URL /v1/Clean (path-keyed)High (visible)Trivial (path match)GoodLow
HeaderNeeds VaryLow (hidden)Header inspectionGoodMedium
Query paramPoor (cache splits)MediumQuery parsingFairMedium
Date-pinnedCleanLowPer-account lookupExcellentHigh (all versions live)
The recommendation this guide commits to

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.

The deprecation protocol

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.

Cross-reference — detrimental reliance

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.

Timeline of a v1 to v2 deprecation executed over twelve months: announce, dual-run with adoption checkpoints at 50 and 90 percent, two scheduled brownouts, then sunset with a 410AnnounceM0: headers on,changelog, emailsdual-run v1 + v2M3: 50% on v2M6: 90% on v2BrownoutsM9, M10:scheduled 410sSunsetM12: v1 → 410+ migration doc
Figure 6.1 — A deprecation with checkpoints, not a cliff. Announce at M0 (Deprecation/Sunset headers, changelog, direct emails); dual-run while adoption climbs, gated at 50% (M3) and 90% (M6); two scheduled brownouts (M9, M10) flush out inattentive callers while stakes are low; sunset at M12 returns 410 with a migration pointer. Notice starts the clock; the checkpoints and brownouts are what make the sunset safe.
Dual-run architecture: v1 and v2 as thin adapter layers over one internal model, so both contracts are served from one codebase and renames live in the adapterv1 clientv2 clientv1 adapterlocation: stringv2 adapterlocation: {rack,slot}one internal modelrack + slot columns
Figure 6.2 — Dual-run is one codebase, two contracts. v1 and v2 are thin adapters over a single internal model; the shape difference (a flat 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.

The contract artifact: OpenAPI

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" }
Note

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.

Module 07 Webhooks: the roles reversed

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.

The role reversal

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.

Cross-reference — m5, reversed

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.

Delivery semantics: at-least-once, unordered

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.

The load-bearing idea

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.

The delivery lifecycle

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.

State machine of webhook delivery: created to delivering, to delivered on 2xx or retrying on failure with a backoff schedule, to dead-lettered when exhausted, with a manual replay edge back to delivering and signature verification annotated on the delivering-to-consumer edgecreateddeliveringdelivered ✓retryingdead-lettered2xxfail / no 2xxbackoff 1m/5m/30m/2h/6hexhaustedmanual replayconsumer verifies signature beforeprocessing; bad signature = terminal 4xx,not a retryable failure
Figure 7.1 — Delivery as a visible state machine. created → delivering → delivered on 2xx, or retrying on failure with a documented backoff (1m/5m/30m/2h/6h); exhaustion routes to dead-lettered, which a manual replay can return to delivering. On the delivering edge the consumer verifies the signature first — a bad signature is a terminal 4xx the sender should not retry, not a transient failure.

Signing and replay protection

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.

Signature-verification swimlane: the provider signs and sends an event which the consumer accepts, while a forged request with no valid signature and a replayed request with a stale timestamp are both rejectedProviderConsumerevt_7g3p · t=now, v1=validrecompute HMAC → match, t fresh → 200 acceptforged POST · no valid signatureHMAC mismatch → 400 rejectreplay · valid sig, t=stale (captured)t outside ±5m window → 400 reject
Figure 7.2 — Three requests, one accepted. The legitimate event verifies (HMAC matches, timestamp fresh). A forged request fails the HMAC comparison. A replayed capture has a valid signature but a stale timestamp — which is why the timestamp is inside the signed material and the window is enforced. Constant-time comparison prevents timing side-channels on the secret.
Cross-reference — your home turf

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.

Consumer ergonomics

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 state

Module 08 When the caller is a model

A 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.

The newest stranger

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.

Cross-reference — Guide Nº 02

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.

Schema strictness and intent shape

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:

Before and after tool schema for wine search: the endpoint-mirror version with nine cryptic parameters and two internal IDs required, versus the intent-shaped tool with four parameters, an enum region, and described defaultsBefore — endpoint mirrorGET /wines? filter[region_id]= ← internal ID filter[producer_id]= ← internal ID include=producer,varietal fields[wines]=name,price,… sort=-created_at page[size]= & page[after]= price_gte= & price_lte=agent must already know region_id;guesses, chains calls, fails.After — intent shapedsearch_wines( query: string, food_pairing?: string, max_price_usd?: number, region?: enum[ Bordeaux, Burgundy, Rioja, …]) → { wines: [...], has_more }enum'd region (no invented values),defaults described, one call = one intent.
Figure 8.1 — Mirror vs intent. The endpoint-mirror tool demands internal IDs the agent can’t know and exposes pagination and sparse-fieldset machinery it will misuse. The intent-shaped 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.
When it misleads — endpoint-mirror tool schemas

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.

Error verbosity inverted

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
}
Agent self-correction swimlane: with a terse error the agent hallucinates a parameter and loops; with a verbose error the remedy prose feeds the next call which succeeds in two turns with no humanTerse errorVerbose errorsearch_wines(region="Borduex")422 "Invalid region."retry region="France" → 422retry filter[region_id]=? → 422loops, burns budget, gives upsearch_wines(region="Borduex")422 closest_valid:"Bordeaux"allowed:[Bordeaux,…]retry region="Bordeaux" → 200two turns, no human
Figure 8.2 — The remedy feeds the next call. A terse ‘Invalid region’ gives the agent nothing to act on, so it invents parameters and loops until its budget runs out. A verbose error carrying 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.

Documentation machines can use

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.

The load-bearing idea

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.

The anti-pattern catalog

Consolidated as the guide’s field reference — each anti-pattern as symptom → diagnosis → corrective, cross-referenced to the module that teaches it.

Anti-patternSymptom you’ll observeCorrectiveModule
RPC-in-REST-clothingURLs full of camelCase verbs (/createEntry)The noun test; model state changes on resourcesm1
200-with-error-bodyDashboards green while integrations failStatus line is the machine channelm2
Error-without-remedySupport inbox: ‘what does this error mean?’Name field, constraint, value; 2am testm3
Offset-on-mutable-collectionsExports miss/duplicate rows, unreproduciblyKeyset cursor for complete iterationm4
Retry-without-jitterSynchronized retry wave re-downs a recoveryExponential backoff with jitter + budgetm5
Unpublished rate limitsClients guess sleep intervals against naked 429sPublish limits; Retry-After + RateLimit-*m5
Breaking-as-minorRaw-HTTP partner breaks on a ‘minor’ bumpWire contract is the promise; new majorm6
Unsigned-webhooksForged events accepted by a consumerHMAC over timestamp+payload; windowm7
Silent auto-disableA week of missing events found in an escalationNotify on disable; delivery logs; replaym7
Endpoint-mirror tool schemasAgent picks wrong tool / chains three callsIntent-shaped tools; enums; descriptionsm8

The contract, one last time

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.

Concept index

Resource
A noun with identity: a stable ID, a URL, and state that methods act on.
Action resource
The durable record a verb leaves behind — model ‘consume a bottle’ as a consumption, not a /consume RPC.
RPC-in-REST-clothing
Verb endpoints (/createEntry) wearing HTTP as mere transport; the symptom of modeling procedures instead of state.
GraphQL
Client-shaped query language for reads; moves response shaping to callers at the price of query-cost governance.
gRPC
Contract-first binary RPC suited to interior service boundaries — positioned, not taught, in this guide.
Safe method
A method promising no state change (GET, HEAD) — a promise prefetchers and crawlers act on without reading your docs.
Idempotent method
Repeating the request leaves the server as one send would (GET, PUT, DELETE — never POST).
ETag
A representation’s version fingerprint; powers 304 revalidation on reads and lost-update protection on writes.
Optimistic concurrency
Detecting conflicting writes at commit time (If-Match → 412) instead of locking up front.
Request ID
The correlation token every response carries so a stranger’s bug report can find your logs.
Error code
The stable machine-readable string clients branch on; messages are for humans and may change without notice.
Problem Details
RFC 9457’s standard error envelope (application/problem+json) — adopt it or diverge consciously.
Error catalog
The enumerated set of every error an endpoint can return, with status, retryability, and remedy — a contract exhibit.
Offset pagination
Pages as LIMIT/OFFSET positions in a moving list; mutation shifts rows across page boundaries, skipping and repeating.
Keyset cursor
A position anchored to stable sort-key values (created_at, id) — immune to rows shifting beneath it.
Opaque cursor
A token clients cannot construct or decode — the opacity is contract freedom to change mechanics later.
Idempotency key
Client-minted identity for an operation; the server executes once and replays the stored response on resends.
At-least-once delivery
The only honest promise over unreliable networks: things arrive, possibly more than once.
Exactly-once illusion
At-most-once execution per key composed with at-least-once delivery — engineered, not physics.
Backoff with jitter
Retry spacing that grows and desynchronizes, so recovery traffic doesn’t become the second outage.
Token bucket
Rate-limit algorithm that absorbs bursts while capping sustained rate; refill rate is the published limit.
Retry-After
The header that turns ‘slow down’ into a machine-readable instruction a client can engineer against.
Breaking change
Any change that can make a well-behaved existing client misbehave — decided by the contract, not your intent.
Tolerant reader
The documented client obligation to ignore unknown fields and enum values — the clause that makes additive change safe.
Sunset header
The machine-readable end-of-life date (RFC 8594) that starts the deprecation clock in-band.
Dual-run
Serving old and new versions as adapters over one internal model while clients migrate.
Brownout
Scheduled temporary failures before sunset that surface inattentive integrators while the stakes are still low.
OpenAPI
The machine-readable contract artifact: source of docs, SDKs, mocks, and mechanical breaking-change detection.
Webhook
The roles reversed: you call the integrator’s server — inheriting every obligation you impose on your own callers.
Dead letter
Where a delivery lands after retries are exhausted; it must be visible and replayable, not a silent grave.
Replay attack
Re-sending a captured signed webhook; countered by signing the timestamp and enforcing a tolerance window.
Thin event
A payload carrying identity, not state (‘ce_9d2f changed — fetch it’); trades a round-trip for immunity to disorder.
Intent-shaped schema
A tool schema modeling what callers want to accomplish, not how you store the data.
In-band recovery
The property that everything an agent needs to self-correct lives in the response itself — there is no other channel.

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.