Orders & fulfillment
An order is created from the public surface (your storefront's checkout) and
managed from the merchant surface. Examples reuse the
api() helper.
Legacy order creation remains live via
POST /public/orders. New BYO checkout builds should prefer checkout sessions, which bind the server-authoritative total and payment handoff before the order is created.
Create an order (public)
POST /api/v1/public/orders — tenant header, no auth.
const order = await api("/public/orders", {
method: "POST",
body: JSON.stringify({
customerName: "Dana Reyes", // required, ≤200
customerEmail: "dana@example.com",// required, valid email
items: [ // required, ≥1 line
{ itemId: "…", variantId: "…", name: "Trail Pack 38L", quantity: 1, unitPriceInCents: 18900 },
],
customerPhone: "+1…", // optional, ≤50
notes: "Leave at the side door", // optional, ≤2000
}),
});What the legacy public order path captures.
POST /public/ordersrecords line items only. The persisted order'ssubtotalInCentsis the sum of the lines,taxInCentsis0, andtotalInCentsequals the subtotal — there's no shipping line, and a coupon you previewed at the cart is not attached. Tax, shipping, and discounts are display-only estimates unless the storefront uses the M3 checkout-session flow. If your legacy cart UI shows a Total that includes tax or shipping, it will be higher than the order the API stores — reconcile your displayed total tosubtotalInCents, or clearly label the extras "estimated, finalized at checkout."
The M3 litecheckout/BYO checkout path adopts checkout sessions instead of this
legacy endpoint. Once a storefront adopts that flow, a bound checkout session
can return computed tax as public taxInCents / totalInCents values when a
tax provider is explicitly enabled; the confirmed order then receives that
server-authoritative total.
Public order reads — GET /public/orders, /public/orders/:id,
/public/orders/number/:num — strip the merchant notes field (it's an
internal scratchpad, below).
Manage an order (merchant)
Reads (GET /merchant/orders, /:id, /number/:num) are open to any member.
Order-status and note mutations require orders:operate; Owner/Admin retain
broad access, and Staff needs that group:
// Order status
await api(`/merchant/orders/${id}/status`, { method: "PATCH", token,
body: JSON.stringify({ status: "CONFIRMED" }) });
// Internal notes (merchant-only; send null to clear)
await api(`/merchant/orders/${id}/notes`, { method: "PATCH", token,
body: JSON.stringify({ notes: "Refunded shipping as a courtesy" }) });Deprecated compatibility endpoint.
PATCH /merchant/orders/:id/fulfillment-statusis rejection-only. It returns409 LINE_FULFILLMENT_REQUIREDfor every order and never changes fulfillment, inventory, Sales, or audit state. Do not build a status setter against it.
Order status and the fulfillment projection
OrderStatus — PENDING → CONFIRMED → PROCESSING → COMPLETED, with
CANCELLED reachable from any non-terminal state. COMPLETED and CANCELLED
are terminal.
FulfillmentStatus — UNFULFILLED, PARTIALLY_FULFILLED, or FULFILLED
is a read projection derived from explicit fulfillment records. It is a
distinct axis from order status, so a PROCESSING order can be
PARTIALLY_FULFILLED; clients do not advance it directly.
Invalid order-status transitions are rejected — you can't jump PENDING → COMPLETED.
Record physical fulfillment
Use the Sales/Commerce record and explicit line quantities:
- For a manual direct handoff,
POST /merchant/commerce/orders/:recordId/fulfillmentscreates one line-managedSHIPPEDfulfillment. Supply a stablex-idempotency-keyand the required customer-notification decision. - For a carrier label, first
POST /merchant/orders/:orderId/shipping-label/quotewith the selected lines, loaded package, carrier, and service. This read-only call returns the exact charge, decision classification, acknowledgement/reason requirements, and aselectionQuoteFingerprint; it never buys postage. After the merchant reviews that result,POST /merchant/orders/:orderId/shipping-labelwith the matching lines/package/carrier/service, the returned fingerprint, requiredoverrideReason(if any), a high-risk spendreason, and a stablex-idempotency-key. The server freshly recomputes and exactly preflights the reviewed selection before its durable spend claim. A successful purchase stops atREADY: the label may be printed, but the goods are still with the merchant. Manual/free methods and orders with no captured carrier service require an explicit actual-postage confirmation and reason; they make no recommendation, carrier-equivalence, savings, or customer-promise claim. - After physical carrier custody,
POST /merchant/commerce/orders/:recordId/fulfillments/:fulfillmentId/handoffmoves that exactREADYpackage toSHIPPED, consumes its inventory, and records SEND or SUPPRESS durably. Matching retries use the samex-idempotency-key.
The historical order-level tracking route is edit-only. It may repair metadata
on one existing compatible SHIPPED/DELIVERED row, but it cannot create a
shipment, assign quantities, advance a rollup, consume inventory, or send a new
shipment email. Ambiguous or unreconciled history fails closed.
Wire casing. Both enums serialize as
UPPER_SNAKEon the wire — the exact strings shown above (CONFIRMED,UNFULFILLED, …) — for both the values you read back and thestatusyou send in PATCH bodies. Compare against those literal values; don't assume lower-case.
notesis a merchant-internal field, never returned on public order reads. A customer-facing order message channel is a later epic.
Related
- Returns / RMA — the post-fulfillment path
- BYO checkout API — server-authoritative checkout sessions and payment handoff
- Shipping — rates quoted at the cart
- Pricing rules — discounts applied before checkout