Resources Have State Machines
An order moves created → paid → processing → shipped → delivered, and not one step in any other order. If the contract does not say which transitions exist, every consumer invents its own machine — and the server enforces a third one.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The machine exists whether you document it or not
Every resource with a status field has a state machine. The only question is where it lives. If the contract is silent, the machine exists in three inconsistent copies: the server's validation code, the web client's button-enabling logic, and each integrator's assumptions. The copies drift, and the drift surfaces as 500s on transitions the server never expected, or as UI offering buttons the server will reject.
An order flow makes it concrete: created → paid → processing → shipped → delivered, with cancelled reachable from created and paid but not from shipped. That last clause is business policy — "we cannot cancel what is already on a truck" — and it belongs in the contract with exactly the same force as any field type. A consumer that shows a cancel button on shipped orders is not buggy; it was never told.
Documenting states is the easy half. The transitions are the contract: which pairs are legal, what each transition requires (payment confirmation before paid), what side effects it triggers, and who may trigger it (the customer can cancel a created order; only the warehouse system moves processing → shipped). A state list without a transition table is a word list without a grammar.
Illegal transitions are contract violations, not surprises
The contract must say what happens when a client attempts created → delivered. The honest answer is a 409 Conflict with a machine-readable error naming the current state and the legal transitions from it — enough for the client to recover without a support ticket (see The Error Model: Structure Over Apology). A 400 says "your request was malformed", which is false; a 500 says "we broke", which is worse; silently ignoring the write is the cruelest option, because the client believes it succeeded.
The rejection payload is where the machine becomes self-describing. Returning current_state and allowed_transitions turns every conflict into documentation: the client that raced another actor (the customer cancelled while the warehouse shipped) learns the new reality in the error itself, refetches, and re-renders. This matters because state transitions race by nature — two actors, one resource — and the conflict response is the contract's answer to the race (pair it with If-Match when the client must act on exactly the state it saw — see Optimistic Concurrency: Versions and If-Match).
POST /orders/ord_42/transitions HTTP/1.1
Content-Type: application/json
{
"to": "cancelled",
"reason": "customer_request"
}HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": {
"code": "invalid_transition",
"message": "Order ord_42 is shipped; shipped orders cannot be cancelled.",
"current_state": "shipped",
"attempted": "cancelled",
"allowed_transitions": ["delivered"],
"request_id": "req_01H…"
}
}Exposing the machine so consumers stop guessing
Beyond rejecting illegal moves, a mature contract lets consumers *ask*. The cheapest mechanism is embedding allowed_transitions (or allowed actions) in the resource representation itself — the UI enables exactly the buttons the server would accept, for this order, for this caller, right now. This also absorbs authorization: the customer sees ["cancelled"] where the warehouse sees ["shipped"], without the client re-implementing permission rules (see Authorization Design in the Contract).
The machine is also your evolution surface, and it changes under the same compatibility rules as any enum. Adding a state (refund_pending between cancelled and a new refunded) is breaking for every client with an exhaustive switch on status — which is why the contract should demand unknown-state tolerance from day one (see Enum Evolution: The New Value That Broke Old Clients). Splitting a state, reordering flows, or tightening a transition guard are all changes consumers must be able to survive; the transition table in the docs is what makes the change reviewable at all.
Resist the shortcut of encoding the machine only in prose. A transition table — from-state, to-state, trigger, actor, side effects — is testable, diffable in PRs, and generatable into docs. Prose descriptions of lifecycles drift from the code within a quarter; the table is the difference between a documented machine and folklore.
FROM TO WHO REQUIRES SIDE EFFECTS
created paid payment system payment confirmed receipt email
created cancelled customer, cron — release inventory
paid processing warehouse stock reserved —
paid cancelled customer — refund, release inventory
processing shipped warehouse carrier scan tracking email
shipped delivered carrier delivery scan review prompt (24h)
anything else → 409 invalid_transition { current_state, allowed_transitions }Key points
- Every resource with a status has a state machine; an undocumented one exists as drifting copies in server, UI and every integrator.
- Transitions — not states — are the contract: legality, preconditions, actor, side effects, all in a reviewable table.
- Illegal transitions return 409 with current state and allowed transitions, turning every conflict into self-documentation.
- Embedding allowed transitions in representations lets UIs and automations follow the server's machine instead of re-implementing it.
- State machines evolve like enums: new states break exhaustive switches, so demand unknown-state tolerance from clients on day one.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → contract: ships
statusas a free string field with the lifecycle described in one doc paragraph. - 2Web client → machine: infers transitions from observed behavior and enables a cancel button on every non-delivered order.
- 3Integrator → machine: writes an exhaustive switch over the four states it has seen in staging.
- 4Warehouse race → client: customer cancels as the order ships; the server flips a coin in un-specified validation code; one actor gets a bare 500.
- 5Team → product: adds a
refund_pendingstate; the integrator's switch throws in production, and the UI renders blank order pages.
- Consumers attempt transitions the server rejects — or worse, the server accepts transitions the business forbids, and shipped orders get cancelled.
- Racing actors receive unusable errors, so clients retry blindly and double-fire side effects (refunds, emails).
- Any lifecycle change becomes a breaking change because no one knows which transitions consumers assumed.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Publish the transition table (from, to, actor, preconditions, side effects) as a contract artifact, reviewed like schema.
- • Reject illegal transitions with 409 and a payload naming `current_state` and `allowed_transitions`; never with 400, 500 or silence.
- • Embed allowed transitions per caller in the resource representation so authorization and lifecycle reach the UI as one fact.
- • Require unknown-state tolerance in client guidance from v1, so the machine can grow (see [[enum-evolution]]).
- • Count `invalid_transition` rejections per client: a spike from one consumer means its copy of the machine has drifted.
- • Alert on transitions occurring in production that the table does not contain — that is the server's validation drifting from the contract.
- • Watch for status values in analytics that no documentation mentions; folklore states are accumulating.
- • New states and transitions are added to the table first, announced, and guarded by unknown-state tolerance on clients.
- • Tightening a transition (new precondition) is semantically breaking even though no schema changes — treat it with a deprecation window like any contract change (see [[backward-compatibility]]).
- • Splitting a state ships as: add new state, dual-report the old one for a window, migrate consumers with telemetry, remove.
- • Maintaining the transition table and conflict payloads is ongoing work; a two-state resource with one actor does not need the ceremony.
- • Embedding allowed transitions couples representations to authorization evaluation on every read — measurable latency on hot list endpoints.
- • Explicit machines make ad-hoc admin fixes harder: support can no longer hand-edit a status without the machine objecting, which is the point, and a cost.