Request and Response Shapes
A client breaks more often on the shape of a field than on the absence of an endpoint. Stagedoor's first API emitted dates in three formats depending on which handler wrote them, prices as floats that came back from the cents-to-euros multiplication as 19.990000000000002, a null that meant "unknown" in one field and "none" in the next, and a status enum that gained a value the mobile app's switch statement had never heard of, on a Friday, with no default branch. None of those was an endpoint missing. Every one of them was a shape the client had parsed correctly for months until the day it changed.
Shapes are a contract, the same as the methods and status codes of Chapter 2, and the rules are few and unforgiving. This topic states them once: one format for time, one representation for money, three distinct meanings for a missing value, enums that grow and never shrink, one item shape per resource, and a status field instead of a pile of booleans. Every rule has a bug behind it that Stagedoor shipped.
Dates and Times
Every timestamp is an RFC 3339 string with an offset: 2026-10-04T19:30:00Z, or 2026-10-04T20:30:00+01:00 for the same instant written from the venue's side. The server emits exactly one format and the client parses exactly one, and every language's standard library handles it. The offset is the part people drop and the part that matters. A concert at 19:30 in one city is at 18:30 for a buyer in the next, and a starts_at of 2026-10-04T19:30:00 with no offset cannot say which 19:30 it means. Stagedoor stores timestamptz in Postgres for the same reason: the instant is stored, and the wall-clock time is derived for whoever is looking.
Unix timestamps, an integer count of seconds, are for machines that never show them to a human: the Deprecation header of Topic 17 uses one because the value is compared, never displayed. Everywhere a person, a log reader or a support engineer will see the value, the RFC 3339 string wins, because 1791315000 tells nobody what evening it is. One more rule: a date without a time, an event's public listing date, is 2026-10-04 and nothing else, never a midnight that a timezone can shift to the previous day.
Money
An amount is an integer in the currency's minor unit with the currency beside it: price_cents: 4500 with currency: "EUR". A float is wrong on the first operation, because 0.1 and 0.2 have no exact binary representation and the sum of the two is not 0.3. A basket of three seats whose 1999 cents had been turned into euros by a multiplication came to 59.970000000000006 in Stagedoor's first version, and the refund computed from the stored total did not match the charge Payrail had taken, by a cent, on 4 percent of orders. A string decimal, "19.99", is correct but forces every client in every language to carry a decimal library to add two prices. Integer cents need nothing: every language adds integers correctly, and Payrail's API takes integer cents for the same reason.
The book uses cents everywhere and says so in the schema, with the unit in the field name. price_cents cannot be mistaken for euros the way price can. The currency travels with the amount because a number without a currency is not an amount, and an organizer who lists in one currency and a buyer who pays in another will eventually meet in the same table.
null, Absent, and Empty
A missing value can mean three different things: the field is unknown, the field was not sent, or the field is empty. The place this bites is PATCH. A client that patches an event to change its title sends {"title": "New title"} and expects starts_at untouched. A client that wants to clear an event's optional description sends {"description": null} and expects it cleared. If the server treats null and absent the same way, one of those two clients is wrong, and in Stagedoor's first version it was the second: there was no way to clear a description, ever, and the organizer dashboard sent a single space as a sentinel for two years.
class EventPatch(BaseModel): model_config = ConfigDict(extra="forbid") title: str | None = Field(default=None, min_length=1, max_length=200) description: str | None = None starts_at: AwareDatetime | None = None # in the handler: only the fields the client actually sent changes = patch.model_dump(exclude_unset=True) # {"description": None} -> clear the description # {"title": "New title"} -> change the title, touch nothing else
The model declares every patchable field as optional, and the handler asks the parsed model which fields were set, not which fields are non-null. A client that sent description as null shows up in the set with a value of null, and the handler clears the column. A client that did not send it does not show up, and the column is untouched. The rule is stated once in the schema, absent means unchanged and null means cleared, and the request model enforces it so no handler has to remember.
Enums and the Value You Have Not Added Yet
status: "paid" is a string enum with four values in Stagedoor's canon: pending, paid, failed, refunded. The mobile app switched on it with no default branch. The day partially_refunded appeared, every order screen in the app crashed on the orders that had it, which were the orders of the buyers already most annoyed. The contract has two halves, and both are written into the API document. Clients must tolerate an enum value they do not know, by treating it as "other" and rendering something sensible. The server never removes or renames a value, because every stored order with that status becomes unrepresentable the moment it does.
Adding a value is safe under that contract and Topic 17 relies on it: a new status ships without a version. Removing one is a breaking change under any versioning scheme, and the honest way to retire a status is to stop producing it and leave it in the enum for as long as a row carries it, which for orders is forever. The switch without a default is the client's half of the mistake; the server's half is believing that its list of values is the client's list too.
Envelopes, Ids, and Consistency
Three rules that each prevent a version bump. First, one item shape per resource, identical in a collection and alone, so the client has one parser; Topic 12 said it and this topic repeats it because the two-format starts_at was a shape bug, not a URL bug. Second, ids are strings, even when they are numbers today. A JavaScript client stores every JSON number as a double and silently rounds any integer above 2 to the 53rd power, about 9 quadrillion. A sequence will not reach that, but a snowflake id or a hash will, and the buyer with the unlucky id gets a 404 for an order that exists. "id": "184233" costs nothing and cannot be rounded. Third, every response is a top-level object, never a bare array, so a field can be added beside the list without moving the list.
Booleans Are Not a Status
An order with is_paid, is_cancelled and is_refunded as three booleans has eight possible states, of which three are legal. Nothing in the shape stops a row from being paid, cancelled and refunded at once, and a client rendering it has to decide which flag wins. One status field with an enum allows exactly the legal states and nothing else. The state machine of the order, which Chapter 10 draws in full because the Payrail webhook drives it, is visible in the shape: the client can see that pending becomes paid or failed, and that refunded is a terminal state, by reading the list of values and the transitions the API document names beside them.
The trap is that booleans look simpler on day one. is_paid is a single check and status == "paid" is a single check too, so the shapes cost the same to read. They cost differently to extend. The fourth boolean is a sixteen-state space; the fifth enum value is one more line. When a resource has more than one yes-or-no fact about its lifecycle, the facts are one status, and the booleans are the state machine written badly.
- Floats for money — three seats whose price came from 1999 cents multiplied by 0.01 total 59.970000000000006, the stored total is off by a cent from the charge, and the refund does not match what Payrail took; Payrail's own API takes integer cents for exactly this reason.
- Local time without an offset — a 19:30 concert in one city is 18:30 for a buyer in the next, and a server that stored the string cannot tell which one the organizer meant, so it renders the wrong one for somebody every time.
nullmeaning "unchanged" in aPATCH— the client cannot clear a field, ever, and works around it with a sentinel string that then appears on the public event page as a single space.- Removing an enum value — every stored order with that status is now unrepresentable to the client, and the
switchbranch that handled it is dead code that still runs on the rows that carry it. - Ids as JSON numbers — a JavaScript client rounds anything above 2 to the 53rd power without an error, and the order lookup returns 404 for the buyer whose id happened to be large.
- Three booleans where one status belongs — eight possible states for three legal ones, a row that is paid and cancelled at once, and a client that has to invent a precedence rule the server never wrote down.
- Emit RFC 3339 with an offset for every timestamp, integer minor units with a currency for every amount, and a string for every id, with the unit in the field name where a number could be misread.
- Distinguish absent from
nullin everyPATCHmodel by reading which fields were set, and state the rule once in the schema. - Add enum values freely, never remove or rename one, and require clients to treat an unknown value as "other" in the API document.
- Wrap every collection in an object and keep the item shape identical in a list and alone, so each resource has one parser on the client.
- Model a resource's lifecycle as one status enum with named transitions, never as a set of booleans.
Knowledge Check
Stagedoor's first API stored prices as floats. Where did the first wrong number appear?
- When the request body was parsed, because JSON has no native float type
- On the first multiplication, when 1999 cents became 19.990000000000002 euros a seat
- In the database, because Postgres rounds floats to two decimal places on insert
- At Payrail, which rejected the charge because the amount had too many decimal places
An organizer submits starts_at as 2026-10-04T19:30:00 with no offset. What can the server not determine?
- Which calendar day the concert falls on, since the day changes across the date line
- Whether the string parses at all, since RFC 3339 requires the offset to be present
- Which instant on the timeline 19:30 refers to, since the same wall time exists in every zone
- How precise the time is, since a value with no offset is only accurate to the hour
Under Stagedoor's PATCH rule, absent means unchanged and null means cleared. Which client behaviour does this rule make possible that the alternative rule cannot?
- Changing one field of an event without resending the other eleven fields it already carries
- Rejecting a request that carries a field name the server does not recognize
- Replacing the entire event in one request instead of sending a partial update of it
- Clearing an optional field like the description, instead of overwriting it with a sentinel
The server adds partially_refunded to the order status enum. Why is this safe under the contract, when removing refunded would not be?
- Clients must tolerate unknown values, but a removed value orphans every row that carries it
- A new value is appended at the end of the list, so existing clients keep their positions in the enum
- The server can only add values in a new API version, and old clients never see the new version
- A new value is sent as a string and a removed one as null, so the client's parser catches only the removal
Why does Stagedoor send every id as a JSON string, when the order id is an integer in Postgres?
- Because a UUID cannot be represented as a number, so all ids follow the UUID convention for consistency
- Because a JavaScript client stores JSON numbers as doubles and rounds large integers without an error
- Because a string id compresses better on the wire, and the seat map response is fetched 2,600 times a second
- Because Postgres compares string ids faster than integer ids when the column is indexed
You got correct