Resources, Not Actions
An API designed around actions has as many endpoints as the code has functions and no structure a client can predict. Stagedoor's first surface was thirty-one of them: POST /doHold, GET /getSeatsForEvent?id=, POST /cancelOrder, POST /releaseHoldForUser, each one added on the day a screen needed it and named after the function that served it. A client that wanted the tickets for an order had to read the source to learn the URL. An API designed around resources, events, seats, holds, orders and tickets, has five nouns with a known set of verbs on each, and a client that has fetched one order can guess the URL of its tickets without ever having seen it.
Marek's redesign is the worked example of this chapter, and the URLs it produces are the ones the rest of the book uses. GET /events/{id}/seats is what Chapter 9 puts in Redis. POST /orders is what Chapter 7 gives an idempotency key. GET /tickets/{code} is what the scanner reads at the door. Naming them once, properly, is why the later chapters can talk about mechanics instead of arguing about paths.
The Nouns Come From the Domain
An event has seats. A buyer holds a seat. A hold becomes an order. An order has tickets. Those four sentences are the domain, and each noun in them is a resource with a URL. The verbs come from HTTP and carry the promises of Chapter 2: GET reads and may be cached, POST creates and may not be retried without a key, PATCH changes part of a thing, DELETE removes it. The client learns the grammar once and applies it to every noun.
# events are public; the organizer's own are filtered by identity (Chapter 5) GET /events # collection, cursor-paginated GET /events/{id} GET /events/{id}/seats # the seat map: 2,600 reads/s at on-sale PATCH /events/{id} # state transitions: draft -> on_sale -> closed # a hold is the buyer's claim on one seat for 10 minutes POST /holds # body: event_id, seat_label GET /holds/{id} DELETE /holds/{id} # release early; a hold that expires is deleted by the worker # an order converts holds into tickets and charges the buyer POST /orders # Idempotency-Key required (Chapter 7) GET /orders/{public_id} GET /orders/{public_id}/tickets POST /orders/{public_id}/refunds # the action as a noun, see below # what the scanner reads GET /tickets/{code}
The table is fourteen lines and it replaces thirty-one endpoints. The resources are the tables of Chapter 1 seen from outside, but not one to one. payments is not a resource; what Payrail said about an order is visible inside the order, and the client never addresses a payment on its own. idempotency_keys, outbox and webhook_events are never resources at all: they are the machinery of Chapters 7 and 10, and a client that could read them would be reading the service's memory. A resource is a thing the client has a reason to name; a table is a thing the service has a reason to keep. The two lists overlap without being equal.
Identifiers That Do Not Leak
orders.id is a sequence. The first order Stagedoor ever took was 1, and the order placed at 19:42 tonight is 4,118,233. Put that number in the URL and GET /orders/4118233 invites GET /orders/4118232, and a script that walks downward from tonight's number reads every order the authorization check fails to hide. It also leaks a business fact for free: an organizer who places one order on Monday and one on Friday knows how many orders the whole platform took that week, to the unit. That is why the orders table has two ids. The sequence is the primary key, used by every join, and it never leaves the process. The public_id is a UUID, generated at insert, and it is the only order identifier a client ever sees.
Seat labels are the other kind of identifier. 14C is a natural key: it is printed on the seat, it is what the buyer types, and it is meant to be guessed. Exposing it costs nothing because there is nothing to protect; every seat in a public event is public. The test is not "is this a database id" but "does knowing this one let a stranger find the next one, and does the next one belong to someone else." Event ids fail that test too in principle, and Stagedoor exposes them anyway, because every event is a public listing and enumeration is the catalogue page. Users and orders are the two resources where the sequence stays inside.
Actions That Are Not CRUD
Cancelling an order is not a DELETE. The order still exists after the cancellation, with a refund against it, a payment record Payrail will ask about, and a row in the organizer's report. A client that deleted it would find a 404 where its history should be. Publishing an event is not a PUT of the whole event either; the client is changing one fact, the status, and sending the other eleven fields back means sending stale ones. Two honest patterns exist for an action that is not create, read, update or delete, and Stagedoor uses both with a rule for which.
PATCH /events/8812 Content-Type: application/json If-Match: "v7" { "status": "on_sale" } # from draft: 200 with the event, status on_sale, ETag "v8" # from closed: 409, type .../problems/illegal-transition, detail "closed -> on_sale is not allowed"
The first pattern is a state transition: the client patches the one field that names the state, the server checks that the transition is legal, and an illegal one is a 409 with the Problem Details shape of Topic 15. Publishing an event, closing it, and cancelling an order are all transitions of the resource's own status field. The second pattern is a noun sub-resource: POST /orders/{public_id}/refunds creates a refund, which is a thing with an amount, a provider reference and a status of its own that Chapter 10 reconciles. The rule that chooses between them is whether the action produces something with a life of its own. A cancellation is a fact about the order; a refund is a record Payrail will settle three days later. One convention per kind, written down, and never POST /cancelOrder again.
Nesting and Its Limit
/events/{id}/seats says that seats belong to events, and that is true and useful: a seat has no meaning outside its event, and the seat map is fetched as a unit. Two levels is the ceiling. /organizers/{o}/events/{e}/seats/{s}/holds carries four identifiers, and the handler must verify that the event belongs to the organizer, the seat to the event and the hold to the seat before it does anything, because a path where they disagree is either a client bug or an attack. Chapter 5 shows what happens when one of those checks is skipped: a buyer reading another organizer's data through a URL that was syntactically fine.
The hold is the case that shows the rule. A hold belongs to a seat, which belongs to an event, and the path could say so. It does not, because a hold has its own id, its own expiry and its own client: the checkout page holds the id it was given and releases it with DELETE /holds/{id} without ever naming the event again. Every resource gets a top-level URL. Nesting is for the collection that only makes sense under its parent, and it is never the only way to reach a thing.
Collections and Their Shape
A collection returns a list and metadata about the list. The metadata is a count where counting is cheap and a cursor where it is not, and Topic 16 is about which is which. What matters here is that the item shape inside a collection is the item shape everywhere. An order in GET /orders has the same fields, the same names and the same types as the order in GET /orders/{public_id}, so a client has one parser per resource and not one per endpoint. The first version of Stagedoor returned starts_at as a date in the event list and as a date-time on the event page, and the mobile app carried two parsers and a bug in one of them for eight months.
The list itself lives inside an object, never as a bare array at the top level. {"items": [...], "next_cursor": "..."} can grow a field tomorrow; [...] cannot grow anything without breaking every client that indexed into it. Topic 13 makes the same argument for every response, and Topic 17 is why it matters.
URLs That Survive
Lowercase, hyphens where a word break is needed, plural nouns, no verbs, no file extensions, and no version in the path unless Topic 17 decides otherwise. /events/8812/seats follows all of it. /getSeatsForEvent.json?id=8812 breaks every rule in one path and, more to the point, describes an implementation: a function named getSeatsForEvent that serializes to JSON. The test for a URL is whether it still makes sense after the code behind it is rewritten. Stagedoor's holds moved from a Redis key with a TTL to a Postgres row with an expires_at during Chapter 6, and POST /holds did not change by a character. A URL is a name for a thing the client cares about, and the client does not care how it is stored.
RPC-style maps one endpoint to one function: POST /holdSeat, POST /releaseSeat. It is the easiest thing to add on the day a screen needs it, impossible to predict from the outside, and the method carries no promise, because everything is a POST and Chapter 2's retry rules cannot see what is inside.
Resource-oriented maps nouns to URLs and verbs to methods. The client learns the grammar once; the cache, the retry loop and the load balancer read the method and behave correctly without knowing the domain. It costs a design session before the first handler, and it pays that back on the first day a client the team did not write shows up.
gRPC, in Topic 18, is RPC done honestly: a schema, generated clients, and every call typed on both sides. POST /doThing over HTTP is RPC done by accident, with none of the schema and none of the HTTP.
- Exposing the sequence id in the URL — order numbers become an enumeration attack that walks downward from tonight's order, and a growth-rate leak that tells every organizer the platform's weekly volume; the UUID was one column away.
- A verb in the URL —
/getSeatsserved as aPOSTcannot be cached by anything, and/deleteOrderserved as aGETis deleted by the first crawler or link-prefetching browser that follows it, which is the Chapter 2 rule broken at the naming stage. - Modelling a state change as
DELETE— the refunded order is gone from the API and still in the database, the client cannot show the buyer her refund, and the organizer's report and the API disagree about how many orders exist. - Three-level nesting — every path carries ids the handler must verify agree with each other, and the one handler that skips the check is the authorization bug of Chapter 5, reachable by editing a number in the address bar.
- A different item shape per endpoint — the event list and the event page emit the same field in two formats, the client carries two parsers, and the second one is wrong for eight months before anyone notices.
- Name the resources from the domain's nouns and write the URL table before any handler exists, so the table is the design and the handlers implement it.
- Expose UUIDs or natural keys, never sequences, and keep two id columns on every table whose rows belong to a user.
- Model non-CRUD actions as a state transition on the resource's status or as a noun sub-resource, choose by whether the action produces a record with a life of its own, and apply the choice across the whole API.
- Cap nesting at two levels and give every resource a top-level URL, so a client can address a hold without naming its event.
- Emit one item shape per resource, identical in a collection and alone, inside a top-level object that can grow a field without a version.
Knowledge Check
A client written for the resource-oriented surface needs the tickets for an order it has never fetched. Why can it guess the URL, when a client of the action-oriented surface had to read the source?
- The nouns and verbs form a grammar the client learned once from any other resource
- The resource surface publishes an OpenAPI document and the action surface never does
- Resource URLs are shorter, so a client trying candidates exhausts the possibilities faster
- The action surface hides its URLs behind authentication and the resource surface leaves them public
Stagedoor's orders table has both a sequence id and a UUID public_id, and only the UUID appears in URLs. What does the sequence leak if it is exposed?
- The identity of the buyer, because the sequence is derived from the user id at insert time
- Every neighbouring order to a script that counts downward, and the platform's order volume to anyone with two orders
- The database's primary hostname, because sequences are allocated per host and the pattern is recognizable
- The idempotency key of the order, because Chapter 7 derives that key from the order's primary key
A buyer cancels a paid order. Which modelling keeps the order visible to the client and the organizer's report after the cancellation?
- A DELETE on the order, with the server marking the row instead of removing it
- A POST to a cancel-order endpoint that carries the order's public id in the body
- A PATCH of the order's status, or a POST that creates a refund under the order
- A PUT of the whole order with the status field changed and every other field repeated
Why does Stagedoor spell a hold as /holds/{id} rather than as /events/{e}/seats/{s}/holds/{h}, when a hold really does belong to a seat in an event?
- Because a path with three ids exceeds the length that the load balancer will forward intact to an instance
- Because a nested path cannot be cached by the CDN, while a flat one can be cached the way the seat map is
- Because a hold can move between seats, so the nesting would be wrong the moment a buyer changed her mind about which seat
- Because every id in a path must be verified to agree with the others, and a hold has its own id its client already holds
You got correct