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

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 positive PRODUCT line quantities. SERVICE, CUSTOM, FEE, DEPOSIT, and ADJUSTMENT lines do not enter the shared physical-shipment rollup.

Direct/manual handoff: POST /merchant/commerce/orders/:recordId/fulfillments creates one line-managed SHIPPED fulfillment. Send a stable x-idempotency-key and the required boolean customer-notification decision. The command serializes overlapping writes, prevents cumulative over-fulfillment, consumes linked inventory once, and derives both Commerce and legacy Order projections.

Native carrier label: keep preparation separate from handoff:

  1. Optionally call POST /merchant/orders/:orderId/shipping-package/recommendation for a read-only, proven-fit active box preset. A no-recommendation result never guesses.
  2. Call POST /merchant/orders/:orderId/shipping-label/quote with the selected lines, confirmed loaded package, carrier, and service. This can make a non-funded provider rating request and returns the exact charge, funding preflight, decision classification, acknowledgement/reason requirements, and an opaque selectionQuoteFingerprint. It allocates no lines and buys no postage.
  3. After merchant review, call POST /merchant/orders/:orderId/shipping-label with the same selection, returned fingerprint, any required overrideReason, a high-risk spend reason, and a stable x-idempotency-key. The server recomputes and re-preflights before the durable spend claim; it never automatically adds provider funds. A successful purchase stops at READY: the label may be printed, but the goods remain with the merchant and inventory is not consumed.
  4. After physical carrier custody, call POST /merchant/commerce/orders/:recordId/fulfillments/:fulfillmentId/handoff. This moves only that READY package to SHIPPED, consumes its line reservations, and durably records whether to send or suppress the shipment notification. Matching retries use the same x-idempotency-key.

An uncertain label-purchase outcome is not permission to buy again. Read its server-derived reconciliation status and use the exact existing purchase's reconcile action only when allowed. See Shipping for the provider, package, reconciliation, cancellation/recovery, and warehouse-output contracts.

Cancel prepared work, not shipped history

Label-free PLANNED/READY work can use canonical fulfillment cancellation. A READY package with current paid native postage must use POST /merchant/orders/:orderId/fulfillments/:fulfillmentId/cancel-prepared-shipment: the package remains READY and handoff stays blocked until authoritative void evidence retires the postage. An exhausted cancellation has a separate Owner/Admin carrier-check recovery that performs one provider GET, never a second void. Already-retired external/legacy postage has its own explicit Owner/Admin acknowledgement path and cannot bypass current or ambiguous native postage.

SHIPPED or DELIVERED quantities are immutable fulfillment evidence; route returned goods through Returns / RMA instead of rewinding the fulfillment.

Batch warehouse output

GET /merchant/shipping/warehouse-outputs lists bounded physical shipments with packing-slip data and label eligibility. The CSV endpoint exports 1–50 selected rows; the labels endpoint combines only existing purchased PDF artifacts. Neither operation buys postage or changes fulfillment/handoff state, and a missing or unsafe label fails the complete batch rather than being silently omitted.

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.

  • Returns / RMA — the post-fulfillment path
  • BYO checkout API — server-authoritative checkout sessions and payment handoff
  • Shipping — checkout rates plus native post-order package, label, cancellation, and warehouse operations
  • Pricing rules — discounts applied before checkout