Skip to main content

API Design & REST: Contracts Between Strangers

Contracts Between Strangers

For a whole section we built the front door — balancers, health checks, rate limits — and the entire time, something has been flowing through that door that we treated as a black box: the requests themselves. GET /api/users. POST /checkout. We routed them, spread them, rejected them politely… without ever asking who decides what they look like. Starting now: you do. This section is about the language your servers speak to the world, and it opens with the most consequential interface decision in your system — the API.

Here's the frame that makes API design make sense, and it's worth engraving: an API is a contract between strangers. The developers who will call your API have never met you. They won't read your source code, can't ask you what you meant, and will discover your API at 2am with a deadline. Everything they know about your system is what the interface itself tells them. Your function names can be sloppy because a teammate can walk over and ask; your API cannot, because there is nobody to ask. The interface must carry all of the meaning.

REST's core insight is that you don't need to invent a private language for that contract. HTTP already is a language — it has nouns (URLs), verbs (methods), and a vocabulary of outcomes (status codes) that every developer, browser, cache, and proxy on Earth already speaks. REST is the discipline of reusing that shared grammar instead of fighting it. Do it well and something almost magical happens: a stranger can guess your API — predict the endpoint for a thing they've never read the docs for — because your contract follows the grammar they already know. That guessability is the entire game, and this lesson is how you win it: resources, then verbs, then status codes.

A plain-English intent - a customer places an order - translated into the three-part grammar of a REST contract. Arrows fan the intent into three large slots: the method POST, the verb chosen from HTTP's own set; the path /orders, a plural noun naming the resource with no verb in it; and the outcome 201 Created with a Location header pointing at /orders/1017. A sky strip states the core claim: zero new vocabulary - every part reused HTTP's own grammar, so a stranger guesses it on the first try. A red sin strip shows the same idea negated: GET /deleteUser?id=42 struck through - a verb in the URL when HTTP already has verbs, and a GET that mutates, which caches and crawlers will punish - labelled RPC in a trench coat.

Resources: Your API Is Made of Nouns

Start with the subjects of the grammar. REST models your system as a set of resources — the things your API is about — and gives each one an address. Not actions. Things. An e-commerce system isn't "createOrder, getProduct, cancelShipment"; it's orders, products, shipments — nouns, each living at a URL.

The modern convention, followed by Stripe, GitHub, and Twilio alike, is plural nouns: /products is the collection, /products/17 is one item in it. That one choice gives you a complete, uniform grammar for free — the same shape works for every resource you'll ever add, and a stranger who has seen /products/17 can correctly guess /orders/1017 without opening your docs. That's the guessability rule doing its work.

Ownership nests, but shallowly. /stores/8/products reads exactly like the English "store 8's products" — one level of nesting encodes a real relationship. But resist the path safari: /stores/8/managers/3/reports/2026/entries/9 is a URL nobody can guess, remember, or link to. The working rule: nest one level to show ownership, and give anything deeper its own top-level home (/entries/9 — it has an id; it doesn't need an ancestry recital).

And now the number-one sin in all of API design, the one that instantly marks a contract as unguessable: verbs in URLs. /getUser?id=42. /createOrder. /deleteProduct/17. Think about what happened there — HTTP already has verbs, an entire method vocabulary, and these paths ignore it to smuggle the verb into the noun's place. Every verb-URL is its own snowflake a stranger must memorize (/getUser? /fetchUser? /user_get?), where GET /users/42 was guessable by grammar alone. When you see verbs in paths, you're not looking at REST — you're looking at RPC wearing REST's clothes, using HTTP as a dumb tunnel. (One honest exception lives at the end of this lesson.)

Methods: Five Verbs, Two Properties

If resources are the nouns, HTTP's methods are the verbs — and there are really only five you'll use daily. The magic is that each verb comes with formally defined semantics (RFC 9110), so a stranger knows what your endpoint does to the world before reading a single doc:

MethodMeansSafe?Idempotent?
GETread it — change nothing
POSTcreate in a collection / do a thing
PUTreplace the whole resource at this URL
PATCHupdate only the fields I'm sending❌*
DELETEremove it

Two properties carry all the weight. Safe means the request changes no state — GET promises this, and the whole web is built on the promise: caches store GET responses, browsers prefetch GET links, crawlers follow every GET they find. A GET that secretly mutates (GET /users/42/delete — yes, it happens) breaks that machinery spectacularly; real production data has been eaten by a crawler innocently walking "safe" links. Idempotent means doing it twice is the same as doing it once — PUT /users/42 with the same body, or DELETE /users/42, can be repeated harmlessly. POST is the odd one out: two identical POSTs make two orders. Hold that thought — it's the seed of a whole lesson two stops from now (idempotency keys, safe retries); for today, just know which verbs carry the guarantee.

The everyday trap is PUT vs PATCH. PUT means replace: the body you send becomes the entire new resource, and any field you omit is gone. PATCH means amend: only the fields you send change. The classic bug is using PUT like PATCH — send {"name": "New Name"} to rename something via PUT, and congratulations, you've just nulled every other field on the object. Rename = PATCH. Full overwrite = PUT. A stranger relies on you meaning what the verb means.

The five HTTP verbs as a large reference grid with two property columns, safe and idempotent. GET reads and changes nothing - safe and idempotent. POST creates in a collection - neither. PUT replaces the whole thing - not safe but idempotent. PATCH amends only the sent fields - neither. DELETE removes it - not safe but idempotent. Two definition chips at the bottom: safe means read-only, so caches and crawlers may call it freely; idempotent means repeat-proof - once or five times, same end state - which is what the retry question hangs on.

Status Codes: The Outcome Vocabulary

The third part of the grammar answers the caller's question: what happened? HTTP gives you a compact outcome vocabulary, and its first digit is a masterpiece of design — it tells the stranger whose move it is:

  • 2xx — it worked. Nothing to fix.
  • 3xx — look elsewhere. The thing moved; follow the redirect.
  • 4xx — your fault, caller. Fix the request and try again.
  • 5xx — my fault, server. Nothing you did wrong; maybe retry later.

That caller-fault/server-fault split alone is priceless — monitoring alerts on 5xx but not 4xx; clients retry 5xx but fix-and-resend on 4xx. But within the families, precision is what separates a professional contract from a lazy one:

  • 200 OK — success with a body (reads, updates). 201 Created — a POST made something new; include a Location header pointing at it (Location: /orders/1017), so the stranger knows where their creation lives. 204 No Content — success with nothing to say (the natural answer to DELETE).
  • 400 — the request is malformed (broken JSON, wrong types). 422 — well-formed but semantically wrong (email field isn't an email). 401unauthenticated: I don't know who you are (confusingly named "Unauthorized"). 403forbidden: I know exactly who you are, and no. 404 — no such resource (and the polite answer when you won't even confirm it exists). 409 Conflict — the request is fine but collides with current state: a duplicate, a stale version, an edit race. 429 — an old friend from the rate-limiting lesson.
  • 500 — a bug on my side. 503 — temporarily down (you've built the thing that returns this — a draining backend behind a balancer).

And the sin that undoes it all: 200 {"success": false} — the lying status. Return errors inside a 200 and every machine in the chain is deceived at once: caches happily store the failure, monitors see a healthy 200 and stay quiet, retry logic sees success and doesn't retry, and every client must crack open the body to learn what the status line should have said. The status line is the machine-readable contract; the body is commentary. Machines act on the line. Never make it lie.

The four HTTP status families as large colored bands, each read as an answer to whose move is it. Green 2xx: it worked, nobody acts. Sky 3xx: look elsewhere, the client follows it. Amber 4xx: the caller's fault, you fix your request. Red 5xx: the server's fault, we fix our server. The first digit answers the question before you read another word - and the family is the contract: a 200 with an error inside blinds every client's error handling.

See It: Design the Endpoint

Reading grammar rules is one thing; writing the contract is the skill. So here's the code-review chair: five product intents, and for each one you design the endpoint — pick the method, type the path yourself, choose the exact status the caller sees, with a live request-line preview building as you go.

The grader reviews like a senior engineer would, and every miss explains itself with the why: put a verb in the path and it flags the RPC tunnel; reach for PUT on a rename and it shows you the fields you just silently erased; return a lazy 200 on a create and it asks where the Location header went; give DELETE a chatty body and it points at 204. Green across a slot means a stranger could have guessed your choice — which is the entire test.

Fifteen graded decisions across the five intents. The passing bar isn't memorization — it's that by the last one, you're deriving the answer from the grammar: what's the resource, what's the verb's semantics, what precisely happened?

Design the Endpoint — five product intents, and you write the contract for each one: pick the HTTP method, type the path yourself in free text, and choose the exact status code the caller should see, while a live request line previews the contract you are building. The grader checks each slot the way a code reviewer would — plural nouns and no verbs in the path, the method whose semantics actually match the intent, the precise status rather than a lazy 200 — and every miss comes back with the why: a verb in the path means you are tunneling RPC through HTTP, PUT for a rename silently erases every field you did not send, a create that returns 200 instead of 201 with a Location header hides the new resource from the stranger who made it, and a delete that returns a body wastes 204's whole point. Fifteen graded decisions across the five intents; pass the stranger test and the endpoint you design becomes one anybody could have guessed.

Errors That Help: problem+json

A 4xx tells the stranger it's their move — but which move? {"error": "Bad Request"} is a shrug in JSON. The contract isn't done until errors are actionable, and there's a standard for exactly this: RFC 9457, Problem Details (the successor to RFC 7807). One media type — application/problem+json — and one predictable shape:

{
  "type": "https://api.shop.dev/errors/validation",
  "title": "Your request has invalid fields.",
  "status": 422,
  "detail": "2 fields failed validation.",
  "instance": "/orders/draft/88",
  "errors": [
    { "pointer": "/email", "detail": "not a valid email address" },
    { "pointer": "/quantity", "detail": "must be at least 1" }
  ]
}

Read it as the anatomy of a useful "no": type identifies the kind of error with a URI a client can switch on (and a human can open); title says it in one line; status echoes the code; detail explains this occurrence; instance points at which request; and extensions like an errors array with JSON-pointers tell the caller precisely which fields to fix. A client can build real error handling against this shape once and reuse it across every endpoint you ship — versus reverse-engineering a new snowflake error format per API, which is what most of the industry still inflicts on strangers.

The test for an error response is the same stranger test as everything else: with only what's in this response, at 2am, can the caller figure out what to do next? If not, the contract has a hole in it.

The Stranger Test: A Design Process You Can Reuse

Everything so far compresses into a five-step process you can run on any feature, forever. Take "customers can review products" and watch it work:

  1. Name the resources. What are the things? Reviews. Where do they live? A review belongs to a product: /products/17/reviews — and since a review has its own id, also /reviews/204. Plural nouns, one level of nesting, guessable.
  2. Map the operations onto the verb grammar. Write a review → POST /products/17/reviews. Read them → GET /products/17/reviews. Fix a typo in yours → PATCH /reviews/204. Remove it → DELETE /reviews/204. Notice you invented zero vocabulary — every operation reused the five verbs.
  3. Pick the precise outcome per operation. The POST returns 201 with Location: /reviews/204. The GET returns 200. The PATCH, 200 with the updated review. The DELETE, 204. Someone else's review you may not touch? 403. A review on a product that doesn't exist? 404. Posting a second review where one-per-customer is the rule? 409.
  4. Shape the errors so the caller can act — problem+json, with field pointers on the 422s.
  5. Read it back cold. Could a developer who has never spoken to you predict endpoint, verb, and status for "edit my review"? If yes, ship it. If you had to explain anything — the contract, not the stranger, is what needs fixing.

That's the whole discipline. It's not about pleasing a REST purity committee; it's that predictability compounds. Every guessable endpoint makes the next one cheaper to learn, until your API becomes what the best APIs (Stripe is the canonical example) are famous for: documentation you barely need.

The Trade-offs: Purity vs Pragmatism

Time to be honest about where the grammar creaks, because staff engineers get paid for the edge cases:

  • Not everything is CRUD on a noun. "Cancel an order" — with refund side-effects, notification emails, inventory restock — is a genuine action. The REST-clean move is to reify it as a resource: POST /orders/17/cancellation (you're creating a cancellation), and it works better than it sounds — the cancellation gets an id, a status, a URL you can GET later. But pragmatists up to and including Stripe sometimes ship POST /orders/17/cancel and sleep soundly. The rule "no verbs in URLs" is a default with a purpose (guessability), not a religion; break it knowingly, rarely, and consistently.
  • Chatty vs chunky. Pure resource-per-request design can force a mobile client into six round trips to paint one screen — real pain on real networks (you'll meet the aggregating answers later in this section: composite endpoints, and eventually GraphQL). Designing the resource granularity is the actual hard judgment call REST leaves on your desk.
  • The grammar is a floor, not a ceiling. Method + path + status gets a stranger 90% of the way; the last 10% — naming fields well, consistent casing, dates in ISO-8601, ids as opaque strings — is unglamorous consistency work that separates APIs people tolerate from APIs people recommend.
  • And the meta trade-off: every deviation you ship is permanent. A public API's mistakes calcify the moment the first stranger builds on them — which is why the versioning and evolution problem gets its own lesson soon. Design slowly; you're writing law, not code.

Mental-Model Corrections

Four beliefs that sound reasonable and quietly wreck contracts:

  • "REST just means JSON over HTTP." No — plenty of JSON-over-HTTP APIs are RPC tunnels (verbs in URLs, everything a POST, everything a 200). REST is the constraints: resources with addresses, the uniform verb grammar, statelessness (the §3 lesson working here), outcomes in the status line. The format is incidental; the grammar is the point.
  • "POST is for sending data, GET is for getting data." Method choice is about semantics — safe? idempotent? create vs replace? — not payload direction. A GET that mutates breaks caches, prefetchers, and crawlers; a search with a huge query legitimately POSTs. Ask "what does this do to the world?", not "which way does the data flow?"
  • "Status codes are decoration — the real info goes in the body." Exactly backwards. Machines act on the status line — caches, retries, monitors, SDKs — and never read your body. 200 {"success": false} deceives all of them at once. Line = contract; body = commentary.
  • "More nesting = more RESTful." Deep paths are less guessable, which makes them less RESTful by the only measure that matters. One level for ownership; own address for anything with an id.

Try It Yourself: Review a Real API

Twenty minutes, no code — just the stranger test run against the real world, which is the fastest way to make this lesson permanent:

  1. Read a great contract. Open Stripe's API reference (docs.stripe.com/api) and look at any resource — say, Customers. Before reading each section, predict: what's the path for one customer? For creating one? What status does a create return? Notice your prediction hit rate — that's what a guessable contract feels like from the stranger's chair. Note the shapes: plural nouns, POST-to-collection, ids in paths.
  2. Now audit a messier one. Take any API you use at work or a public one you suspect (weather APIs are a goldmine of sins). Hunt for: verbs in paths, GETs that change state, 200s wrapping errors, status codes that lie. Write down three violations and — the actual exercise — what each one costs a stranger (memorization? broken retries? silent cache poisoning?).
  3. Redesign one endpoint. Pick the worst offender you found and rewrite its contract on paper: resource noun, verb, precise statuses, problem+json error. Run the five-step stranger test on your version.
  4. Bonus, with a terminal: curl -i https://api.github.com/users/octocat and read the response like a contract lawyer — status line, headers, shape. Then hit a user that doesn't exist and appreciate a well-formed 404 with a documented error body.

The skill being trained isn't memorizing verbs — it's hearing an interface the way a stranger hears it. Once that clicks, you can't unhear it in your own designs.

Recap & What's Next

An API is a contract between strangers — people with nothing but the interface — and REST is the discipline of writing that contract in a grammar the whole world already speaks:

  • Resources are nouns — plural (/orders, /orders/1017), nested one level for ownership, never verbs (/createOrder is RPC in a trench coat).
  • Methods are the verbs, with formal semantics: GET is safe, PUT/DELETE are idempotent, POST is neither (a fact with consequences we'll meet again shortly), and PUT replaces while PATCH amends.
  • Status codes are the outcome vocabulary — the first digit says whose move it is; precision (201+Location, 204, 403 vs 404, 409, 422) is what strangers build reliable clients against. The status line never lies.
  • Errors are part of the contract — problem+json turns "no" into "here's exactly what to fix."
  • The stranger test is the process: nouns → verbs → precise outcomes → actionable errors → could someone who's never met you guess it?

The contract you can now write handles one resource at a time, politely. But the moment a stranger asks for GET /products on a real catalog, you're staring at two million rows and a decision: you can't return everything. Slicing collections without breaking clients — offsets that lie when data shifts, cursors that don't — is pagination, the next lesson.