Skip to content

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/orders records line items only. The persisted order's subtotalInCents is the sum of the lines, taxInCents is 0, and totalInCents equals 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 to subtotalInCents, 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-status is rejection-only. It returns 409 LINE_FULFILLMENT_REQUIRED for 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

OrderStatusPENDING → CONFIRMED → PROCESSING → COMPLETED, with CANCELLED reachable from any non-terminal state. COMPLETED and CANCELLED are terminal.

FulfillmentStatusUNFULFILLED, 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:

  1. For a manual direct handoff, POST /merchant/commerce/orders/:recordId/fulfillments creates one line-managed SHIPPED fulfillment. Supply a stable x-idempotency-key and the required customer-notification decision.
  2. For a carrier label, first POST /merchant/orders/:orderId/shipping-label/quote with the selected lines, loaded package, carrier, and service. This read-only call returns the exact charge, decision classification, acknowledgement/reason requirements, and a selectionQuoteFingerprint; it never buys postage. After the merchant reviews that result, POST /merchant/orders/:orderId/shipping-label with the matching lines/package/carrier/service, the returned fingerprint, required overrideReason (if any), a high-risk spend reason, and a stable x-idempotency-key. The server freshly recomputes and exactly preflights the reviewed selection before its durable spend claim. A successful purchase stops at READY: 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.
  3. After physical carrier custody, POST /merchant/commerce/orders/:recordId/fulfillments/:fulfillmentId/handoff moves that exact READY package to SHIPPED, consumes its inventory, and records SEND or SUPPRESS durably. Matching retries use the same x-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_SNAKE on the wire — the exact strings shown above (CONFIRMED, UNFULFILLED, …) — for both the values you read back and the status you send in PATCH bodies. Compare against those literal values; don't assume lower-case.

notes is a merchant-internal field, never returned on public order reads. A customer-facing order message channel is a later epic.