State reference

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.

There is no fulfillmentStatus
An order used to carry a third field, 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 via POST /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 on status.
  • 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 --> [*]
The automatic transitions. A merchant can also move an order to any status directly — see Setting a status by hand.

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 the Delivered qualifier. 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 from fulfilled or cancelled: closing is bookkeeping, not completion.
completed is retired
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 outranks captured: 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.

Moving backwards does not undo anything
Setting a status changes the label and records who changed it. It never rewinds a side effect: captured money stays captured, sent emails stay sent, shipped fulfillments stay shipped. To reverse money, refund the payment; to reverse a shipment, cancel the fulfillment.
Moving to cancelled is the one exception

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.

Cancelling a subscription's order cancels the subscription

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.

GET /orders/{id}/timeline
{
  "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 }
  }
}
A declined payment appears here and nowhere else
A failure moves no money, so it changes no status and writes no status-history row. The timeline is the only surface that shows it — including the processor's own decline reason, which is why a buyer who says they paid and an order that says 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 already captured with processor: "manual", so the order advances exactly as a processor capture would — including out of draft, so cash against an order created through the API needs no separate checkout call. Refused on a cancelled or closed order. 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. compAmount holds the waived balance. total does 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 is total - compAmount. POST /orders/{id}/comp/reverse withdraws 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 required reason, writes the payment failed with errorCode: "manually_marked_failed", and rolls the order's paymentStatus up again. Only pending, processing and an already- failed payment are failable — 422 payment_not_failable otherwise, because money that arrived is refunded and a hold that is still live is voided. Neither is a failure.
A comped order reads captured

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:

FieldWhat it is
capturedAmountWhat the order actually collected, before refunds. Counts partial captures at the captured figure, not the authorized one.
refundedAmountHow 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 orderQualifierdelivery_state
Every returned item came backReturnedreturned
Some items came back, some did notPartially returnedpartially_returned
Every shipment has an arrival dateDelivereddelivered
At least one shipment left, at least one has not arrivedIn transitin_transit
No shipments, or nothing has left yetno qualifierempty

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.

One qualifier, never stored

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.

FieldTypeWhat it is
onHoldbooleanWhether the order is frozen right now.
holdReasonstring | nullWhy, in the words of whoever placed the hold (1–500 characters).
heldAtstring | nullWhen the current hold started.
heldBystring | nullWho 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 handPOST /orders/{id}/payments, comping the order, and reversing a comp.
  • New fulfillmentsPOST /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}/cancel still 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}/delivered records 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" }. reason is 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.

Holds have their own scope

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}/checkoutdraftpending
  • POST /orders/{id}/authorize and POST /orders/{id}/capturependingprocessing
  • Creating and completing fulfillments moves the order toward partially_fulfilled and then fulfilled, recomputed from the remaining item quantities on every fulfillment change.
  • POST /orders/{id}/status — any status, either direction.
  • POST /orders/{id}/closefulfilled or cancelledclosed
  • 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 only billingAddress, shippingAddress, notes and metadata. Status changes go through /status so 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 every paymentStatus change.
  • order.fulfilled — on entering fulfilled. Replaces order.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 entering cancelled.
  • order.closed — on entering closed.
  • order.held — on a hold being placed. The reason is on the payload.
  • order.hold_released — on the hold being lifted. The order in data is no longer held, so holdReason is null and the reason the hold existed is carried on data.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.