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 draft --> processing: PAYMENT_CAPTURED (recorded) 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 --> [*]
A failed payment does not move the order. paymentStatus becomes failed; status stays where it was. The goods side
of the order has not changed, and the merchant may retry on another card. PAYMENT_FAILED therefore appears in no row
of the automatic-transition table.
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 handed over, some outstanding. -
fulfilled— everything the buyer bought has been handed to the carrier or given to the buyer. This is the happy-path end state. It is handover, not arrival: arrival is a separate, later fact and reads as theDeliveredqualifier. 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.
The eleven payment statuses
paymentStatus is rolled up across every
payment on the order and recalculated on every money movement. You never
set it. It is not the same vocabulary as one payments[].status: an order can be partially_paid, which no single payment
can express, and disputed is derived from
a chargeback flag rather than from any payment's own status.
-
pending— nothing has arrived and nothing is in flight. -
authorized— a hold is in place, no money taken. Capture it or it expires. -
processing— the payment is in flight at the provider: a 3DS challenge, an ACH or iDEAL debit, a bank redirect. It is not a decline. Wait for the provider's webhook; do not retry the charge. -
partially_paid— some money has arrived, less than the total. A deposit taken with the balance still outstanding. -
captured— everything owed has arrived. A fully comped order reads this too, with nothing charged. -
partially_refunded— some of the captured money has been returned. -
refunded— everything captured has been returned. -
failed— every attempt was declined, and none is still in flight. -
voided— the authorization was released without ever being captured. -
disputed— the buyer has charged back. This outrankscaptured: the money is captured AND at risk, and only the half with a deadline is worth a label. Defend it with your provider. -
expired— an authorization lapsed before it was captured. The order is unpaid and the buyer has to pay again.
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.
If the order carries a subscriptionId, reaching cancelled — by /cancel or by setting the status —
cancels that subscription too, immediately, not at the
end of the current period. No further periods are billed. Without this,
a merchant could visibly call off the order a subscription had just
billed and the buyer would keep being charged for it.
It runs one direction only: cancelling the subscription leaves its past orders alone, because they
record money that really moved. It is also idempotent, so a replayed order.cancelled or a second cancelled
order on the same subscription does nothing.
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).
The whole story, not just the status changes
Status history only holds status changes. To read everything that happened
to an order in one list, call GET /orders/{id}/timeline: status
changes, payment attempts, fulfillment events, and holds, merged and
sorted newest first. It takes limit (1–200, default 50) and a cursor, and needs only orders:read.
Every row has the same shape — id, at, kind (
status | payment | fulfillment | hold), title, and source, plus whichever of detail, amount, currency, actorType, actorName and reason that row has — so one renderer
draws the lot.
One more field is conditional on the order, not the row: subscriptionId appears on refund rows of
a subscription-managed order. A cycle is refunded from the subscription —
that is also where the merchant decides whether billing stops — so the row
carries the id of the subscription the decision was made on.
{
"data": [
{
"id": "fulfillment:f_2:created",
"at": "2026-08-14T18:20:11.000Z",
"kind": "fulfillment",
"title": "Shipment created",
"detail": "ups — 1Z999AA10123456784",
"source": "fulfillment"
},
{
"id": "payment:pay_9:failed",
"at": "2026-08-14T17:02:40.000Z",
"kind": "payment",
"title": "Payment failed",
"amount": 12900,
"currency": "USD",
"reason": "card_declined: Insufficient funds",
"source": "provider"
},
{
"id": "status:sh_4",
"at": "2026-08-14T17:03:02.000Z",
"kind": "status",
"title": "Pending → Processing",
"actorType": "system",
"source": "state_machine"
}
],
"meta": {
"pagination": { "cursor": null, "hasMore": false }
}
} pending
can be reconciled from one call.
Money moved by hand
Three endpoints change what an order is owed without a processor being
involved. They all move paymentStatus the same way a card would,
because it is rolled up from the money on the order and does not care how
the money got there.
-
POST /orders/{id}/payments/record— money arrived outside Throttle: cash, a bank transfer to the merchant's own account, a cheque. The payment is created alreadycapturedwithprocessor: "manual", so the order advances exactly as a processor capture would — including out ofdraft, so cash against an order created through the API needs no separate checkout call. Refused on acancelledorclosedorder. Excluded from billable GMV. -
POST /orders/{id}/comp— a comp waives what the buyer still owes. Money already captured stays captured. Nothing is sent to a payment processor and the buyer is never charged.compAmountholds the waived balance.totaldoes not move — the order is still a sale of that size, and the comp is a credit recorded against it, so what the buyer owes istotal - compAmount.POST /orders/{id}/comp/reversewithdraws the credit and the whole balance is owed again. -
POST /payments/{id}/mark-failed— the opposite direction: money that was expected and never arrived. A bank transfer that was promised, a cheque that bounced. Takes a requiredreason, writes the paymentfailedwitherrorCode: "manually_marked_failed", and rolls the order'spaymentStatusup again. Onlypending,processingand an already-failedpayment are failable —422 payment_not_failableotherwise, because money that arrived is refunded and a hold that is still live is voided. Neither is a failure.
paymentStatus answers "is anything
still owed?", not "did anyone pay?". A comp waives the whole remaining
balance, so a comped order owes nothing and rolls up to captured even when no money ever
arrived. There is no separate comped payment status to branch on —
read compAmount > 0 when you need to
tell a waived order from a paid one.
Anything that sums revenue should keep reading total: a comped order is a sale that
happened, and the write-off is a separate figure beside it. Anything
that asks what is still collectable subtracts the comp first.
displayStatus spells it out for humans:
any order carrying a comp reads "Fulfilled · Comped". The comp
replaces the money qualifier entirely, so an order where the buyer had
already paid half before the rest was waived reads the same way — how
much was waived is in compAmount, not
in the label.
How much, not just whether
paymentStatus is a label, so it cannot
answer "how much". A refunded order says a full refund
happened without saying what it was worth, and on a partially-paid order total is not the amount charged. GET /orders/{id} carries two figures
for exactly this, in minor units:
| Field | What it is |
|---|---|
capturedAmount | What the order actually collected, before refunds. Counts partial captures at the captured figure, not the authorized one. |
refundedAmount | How much of that went back, summed from the refund transactions. |
Both come back on the single-order read only. The list endpoint omits them: each one costs a query per payment, which a page of orders would pay per row.
Where the goods are
status says how much of the order has
been handed over. It never says where the parcel is — handover and arrival
are different facts, and only the carrier knows the second one. Location
is a third axis, derived from the shipments on the order's fulfillments
and from its returns, and it reads out of displayStatus as a qualifier.
| The order | Qualifier | delivery_state |
|---|---|---|
| Every returned item came back | Returned | returned |
| Some items came back, some did not | Partially returned | partially_returned |
| Every shipment has an arrival date | Delivered | delivered |
| At least one shipment left, at least one has not arrived | In transit | in_transit |
| No shipments, or nothing has left yet | no qualifier | empty |
A shipment counts as departed once it has a tracking number, and as
arrived once POST /fulfillments/{id}/delivered has
been called on it. Recording arrival also completes a fulfillment that is
still pending or processing — arrival proves handoff — so
the order rolls up to fulfilled without a
separate completion call. Orders with nothing to ship — digital deliveries,
access grants, services — have no location and carry no goods qualifier.
A label carries exactly one qualifier, in this order: a comp wins, then
any money qualifier, then the goods qualifier. So "Fulfilled · Comped" and "Fulfilled · Refunded" never also say Delivered, while a fully captured order
has no money qualifier and so reads "Fulfilled · Delivered".
No qualifier is ever stored. Every one of them — money and goods alike —
is computed from the payments, shipments and returns on each read, so a
label can never drift from the rows behind it. Branch on status, paymentStatus and the filters below;
display displayStatus.
Filtering. GET /orders?delivery=not_shipped, in_transit, delivered
or returned. not_shipped is the fulfiller's queue: an order in processing or partially_fulfilled with no shipment handed to a carrier
and no return — the status scope is part of the predicate, so a cancelled order never
lingers in it. It has no qualifier of its own; the goods column stays blank until
something ships. For the rest, three values, not four: returned matches a partial return too,
because a merchant chasing returns wants both in one list. The filter runs
in SQL over the shipment and return rows, so paging and hasMore stay correct.
Exporting. GET /orders/export.csv carries a delivery_state column with the four
values in the table above (empty for an order with no location). It is
appended as the last column, never inserted, so column positions in
existing spreadsheets do not move.
Holds
A hold freezes an order operationally. It is the state you want while a fraud review, a chargeback enquiry or a credit check is open: the order keeps every fact it already had, and nobody can act on it by accident in the meantime.
A hold is a flag, not a status. It makes no claim about
money and none about goods, so it never touches status, paymentStatus or displayStatus — a held order that was "Processing · Delivered" still reads "Processing · Delivered". Branch on onHold.
| Field | Type | What it is |
|---|---|---|
onHold | boolean | Whether the order is frozen right now. |
holdReason | string | null | Why, in the words of whoever placed the hold (1–500 characters). |
heldAt | string | null | When the current hold started. |
heldBy | string | null | Who placed it. |
What a hold blocks
- Every status transition — checkout, authorize, capture,
the fulfillment roll-up, close, and
POST /orders/{id}/status. - Money moved by hand —
POST /orders/{id}/payments, comping the order, and reversing a comp. - New fulfillments —
POST /orders/{id}/fulfillments.
Each of those returns 409 order_on_hold, and the message
repeats the reason so the caller learns why without a second request.
Nothing is written before the refusal: a refused manual payment leaves no
payment row, and a refused fulfillment leaves no fulfillment row.
What a hold does not block
- Reads. The order, its payments, its fulfillments and its timeline all keep answering. A frozen order you cannot inspect would defeat the review the hold exists for.
- Cancelling.
POST /orders/{id}/cancelstill works, deliberately: cancelling is the usual outcome of a fraud review, and blocking it would force the reviewer to unfreeze the order first — reopening the exact window the hold closed. - Marking an existing shipment delivered.
POST /fulfillments/{id}/deliveredrecords something a carrier already did. Refusing it would not un-deliver the parcel, only lose the date.
Placing and releasing
-
POST /orders/{id}/hold—{ "reason": "fraud review" }.reasonis required. -
POST /orders/{id}/hold/release— no body.
Both return the whole order. A hold can only be placed while there is
still something to act on — pending, processing, partially_fulfilled or fulfilled. On draft, cancelled or closed the request returns 422: there is nothing left for a freeze
to prevent.
Placing a hold that is already in place with the same reason changes
nothing and emits nothing — restarting heldAt would lose how long the order has
actually been frozen. Re-holding with a different reason is a
correction: the reason is rewritten and heldAt is kept. Releasing an order that
is not held returns 422 order_not_on_hold.
A hold writes no status-history row, because it changes no status. It does
show up on GET /orders/{id}/timeline
as a row with kind: "hold" — one for the
hold, one for the release — carrying the reason and who did it. That feed
is the record of how long an order sat frozen and why.
Placing and releasing a hold requires order_holds:write, which is separate
from orders:write on purpose. An
integration that creates and advances orders all day has no business
freezing one, and the reviewer who freezes orders usually should not be
able to rewrite them. A key holding orders:write alone gets 403 insufficient_scopes naming the
scope it is missing.
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. -
order.held— on a hold being placed. The reason is on the payload. -
order.hold_released— on the hold being lifted. The order indatais no longer held, soholdReasonis null and the reason the hold existed is carried ondata.previousHoldReason.
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.