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. The three 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" }) });
 
// Fulfillment status (separate axis)
await api(`/merchant/orders/${id}/fulfillment-status`, { method: "PATCH", token,
  body: JSON.stringify({ fulfillmentStatus: "FULFILLED" }) });
 
// 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" }) });

Status state machines

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

FulfillmentStatusUNFULFILLED → PARTIALLY_FULFILLED → FULFILLED, forward-only (reversals route through Returns, below). Fulfillment is a distinct axis from order status, so a PROCESSING order can be PARTIALLY_FULFILLED.

Invalid transitions are rejected — you can't jump PENDING → COMPLETED.

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.