Resourcesstate machinelifecycletransitionsstatusorders

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Which states can this resource be in, which transitions are legal, who may trigger each — and does the contract say so, or do consumers guess?
Consumers
Clients rendering lifecycle UI ("can this order still be cancelled?"), automations reacting to state changes, and integrators writing code that must not attempt — or must gracefully handle — an illegal transition.
The promise
The contract names the states, the legal transitions between them, who may trigger each, and exactly what an illegal attempt returns — so consumers can build the same machine the server enforces.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

payment confirmedwarehouse acceptscarrier scandelivery scancustomer / timeoutcustomer, refund issuedcreatedpaidprocessingcancelledshippeddelivered
UserLLMAgentToolDataDecisionHumanGuardrail

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

An illegal transition, rejected in a way the client can act on
Request
POST /orders/ord_42/transitions HTTP/1.1
Content-Type: application/json

{
  "to": "cancelled",
  "reason": "customer_request"
}
Response
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.

The transition table — the contract artifact worth reviewing
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.

  1. 1
    Team → contract: ships status as a free string field with the lifecycle described in one doc paragraph.
  2. 2
    Web client → machine: infers transitions from observed behavior and enables a cancel button on every non-delivered order.
  3. 3
    Integrator → machine: writes an exhaustive switch over the four states it has seen in staging.
  4. 4
    Warehouse race → client: customer cancels as the order ships; the server flips a coin in un-specified validation code; one actor gets a bare 500.
  5. 5
    Team → product: adds a refund_pending state; the integrator's switch throws in production, and the UI renders blank order pages.
What breaks
  • 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.

Design the contract
  • • 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]]).
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“The status field is documented — its possible values are listed.”
Reality
A value list is the alphabet, not the grammar. Consumers need the transitions: what follows what, who triggers it, what an illegal attempt returns. Two APIs with identical status enums and different transition rules are different contracts.
Claim
“State validation is server internals; clients just try and see.”
Reality
"Try and see" means every consumer discovers policy through production errors, and UIs cannot know which actions to offer. The machine is consumer-facing by nature — the server merely enforces it.
Claim
“We can add states freely — it is just a new string value.”
Reality
Every client with an exhaustive switch, a UI mapping, or an alerting rule on statuses breaks. New states are enum evolution with side effects; they need the same tolerance rules and announcement discipline. See Enum Evolution: The New Value That Broke Old Clients.

Apply it