Order states
An order has one lifecycle status and one payment status. The lifecycle status answers "how far along is this order?"; the payment status answers "how much of the money has arrived?". Nothing else.
Why two fields and not one
Delivery only moves forwards: once an item ships, it has shipped. Money moves both ways — a captured payment can be refunded, and a refund is not a step backwards through the lifecycle, it is a separate fact about the same order. Folding the two into a single field would mean either losing the refund or corrupting the sequence.
So status is a single ordered sequence,
and paymentStatus sits alongside it. Read
them together and you have the whole picture; that combination is exactly
what displayStatus gives you.
fulfillmentStatus, alongside status. It was removed: the two encoded
one fact, had to be kept in sync by hand, and drifted. Delivery progress
now lives in status itself, with per-item
counts in fulfillmentSummary.
What each field owns
-
status— the lifecycle. Moved automatically by payments and fulfillments, and settable by hand viaPOST /orders/{id}/status. -
paymentStatus— how much of the money has arrived. Rolled up across every payment on the order. Never set directly. -
displayStatus— a human-readable label combining the two, e.g."Processing — Awaiting payment". Derived on every read, never stored, not filterable. Display it; branch onstatus. -
fulfillmentSummary—{ itemsTotal, itemsFulfilled, itemsRemaining }. Items with nothing to deliver (a donation, a fee) are excluded from the total. -
payments[].status— what happened on one transaction. Never a summary of the order.
State machine
stateDiagram-v2 [*] --> draft: order created draft --> pending: INITIATE_CHECKOUT pending --> processing: PAYMENT_AUTHORIZED / PAYMENT_CAPTURED processing --> partially_fulfilled: FULFILLMENT_PARTIAL processing --> fulfilled: FULFILLMENT_COMPLETE partially_fulfilled --> fulfilled: FULFILLMENT_COMPLETE fulfilled --> closed: CLOSE draft --> cancelled: CANCEL pending --> cancelled: CANCEL processing --> cancelled: CANCEL partially_fulfilled --> cancelled: CANCEL cancelled --> closed: CLOSE closed --> [*]
The seven statuses
-
draft— created, checkout not started. No payment expected. -
pending— placed, awaiting payment. -
processing— payment authorized or captured, nothing delivered yet. Fulfillments can now be created. -
partially_fulfilled— some deliverable items delivered, some outstanding. -
fulfilled— everything deliverable has been delivered. This is the happy-path end state. An order with nothing to deliver at all reaches it as soon as payment lands. -
cancelled— called off. Terminal. Authorized payments are voided; captured money is refunded only on explicit opt-in. -
closed— archived out of active views. Terminal. Reachable fromfulfilledorcancelled: closing is bookkeeping, not completion.
completed meant "delivered and paid" —
two facts in one word. It is gone; read status === 'fulfilled' together with paymentStatus instead. Orders that held
it were migrated to fulfilled.
Setting a status by hand
POST /orders/{id}/status with { "status": "fulfilled", "reason": "shipped manually" }.
Any status is reachable from any other, in both directions. Real orders go
wrong in ways a state machine cannot anticipate, and a merchant correcting
a mistake needs to move an order back.
cancelled is the same terminal state POST /orders/{id}/cancel reaches, so
setting it by hand does the same money-side work: every authorized payment on the order is
voided, because a cancelled order must never capture. Captured payments
are left alone — refunding them is an explicit refundCapturedPayments: true opt-in
on /cancel, and editing a status is not
consent to move money back.
For the same reason, a request whose target is cancelled additionally requires the order_cancellations:write scope and
returns 403 insufficient_scopes without
it. Every other target status needs only orders:write.
Every change — manual or automatic — appends a row to the order's history,
readable at GET /orders/{id}/status-history. Each
row carries fromStatus, toStatus, actorType (
user | system | integration), reason, and source (
manual | state_machine | fulfillment | payment | migration).
Endpoint per transition
-
POST /orders/{id}/checkout—draft→pending -
POST /orders/{id}/authorizeandPOST /orders/{id}/capture—pending→processing -
Creating and completing fulfillments moves the order toward
partially_fulfilledand thenfulfilled, recomputed from the remaining item quantities on every fulfillment change. -
POST /orders/{id}/status— any status, either direction. -
POST /orders/{id}/close—fulfilledorcancelled→closed -
POST /orders/{id}/cancel— →cancelled, with payment settlement
POST /orders/{id}/complete has been
removed. Use POST /orders/{id}/status with "fulfilled", or let the fulfillment
roll-up do it.
What you cannot do
-
PATCH /orders/{id}still does not touch status. It accepts onlybillingAddress,shippingAddress,notesandmetadata. Status changes go through/statusso that every one of them is recorded. -
You cannot set
paymentStatus. It is rolled up from the payments on the order. -
You cannot filter or sort on
displayStatus. It does not exist in the database. - You cannot make a manual status change undo a side effect. See the warning above.
Outbound events
-
order.created— emitted exactly once, by whichever path inserts the order. -
order.updated— every status change that does not have its own event, including everypaymentStatuschange. -
order.fulfilled— on enteringfulfilled. Replacesorder.completed, which is retired: nothing emits it, and endpoints and extension manifests that named it were rewritten to the new type in place, so existing subscribers kept receiving fulfillment notifications without changing anything. The old name is still accepted on write requests (and dropped from what gets stored) so a read-modify-write on an old endpoint definition does not fail. -
order.cancelled— on enteringcancelled. -
order.closed— on enteringclosed.
A manual change emits the same event its target status would emit
automatically, with manual: true and an actor on the payload, and action: "SET_STATUS".
A status change ingested from a connected store emits the same event too,
with source: "provider" on the payload and action: "fulfillment_sync" (or "cancel"). Exactly one event per change,
the same as an automatic transition — so a store fulfilment that lands the
order on fulfilled fires order.fulfilled, and one that lands it on partially_fulfilled fires a single order.updated.