CanopyDocs

Endpoints

A tour of the API by topic — what each group of operations is for, with the calls you'll make most. Every operation's full schema lives in the Reference section.

All paths below are relative to your environment base URL (e.g. https://api.staging.canopywms.com) and require a bearer token with the listed scope. See Authentication and Conventions first.

This page is the tour. The Reference section documents every operation with its parameters, request body and response schema, one page per topic: Orders, Inventory, Shipping, Receiving, Returns, Clients and Billing. The same document is served by the API itself at /openapi.json (Swagger UI at /docs).

Every write below (POST, PATCH, PUT, DELETE) accepts an Idempotency-Key header. See Conventions → Idempotency for the rules.

Scoping headers

Two optional request headers narrow what a call sees:

HeaderEffect
X-Client-IdScope the call to one client (brand). Resolve a name to its id with GET /api/clients/lookup. Validated against the credential's tenant; ignored by a client-pinned credential.
X-Warehouse-IdNarrow list calls to one facility, using an id from GET /api/warehouse/warehouses. Omit it for every warehouse. An id that is unknown, inactive, or belongs to another tenant is treated as if the header were absent — the list goes tenant-wide rather than erroring. On POST /api/receiving/shipments it also names the PO's warehouse when the body has no warehouseId (send both and they must match). It never chooses an order's warehouse. On GET /api/orders the warehouseIds query parameter, when sent, replaces it.

Operations marked tenant-wide only refuse a client-pinned credential with 403, as they refuse the brand's own portal users; a pinned credential also reads each record without the warehouse's costs, staff names and bin locations. See Client-pinned and tenant-wide credentials.

Purchase orders

Create purchase orders with your own external PO number, update them, cancel them, and retrieve their received quantities and status. Purchase orders are modeled as inbound shipments under /api/receiving.

OperationMethod & pathScope
Create POPOST /api/receiving/shipmentsreceiving:write
List / search POsGET /api/receiving/shipmentsreceiving:read
Retrieve PO (with lines)GET /api/receiving/shipments/:idreceiving:read
Update POPATCH /api/receiving/shipments/:idreceiving:write
Cancel POPOST /api/receiving/shipments/:id/cancelreceiving:write
PO historyGET /api/receiving/shipments/:id/historyreceiving:read
Overdue POs and discrepanciesGET /api/receiving/shipments/attentionreceiving:read
Received, not yet put awayGET /api/receiving/putaway-queuereceiving:read · tenant-wide only
Create a purchase order (idempotent on your external PO number)
curl -X POST https://api.staging.canopywms.com/api/receiving/shipments \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: 4d8f...-create-po-PO-2026-00184" \
  -H "Content-Type: application/json" \
  -d '{
    "externalPoNumber": "PO-2026-00184",
    "vendorName": "Sunbelt Co-Packers",
    "expectedArrival": "2026-07-05",
    "expectedCartons": 40,
    "lines": [
      { "skuId": "sku_sriracha17", "expectedQuantity": 240 },
      { "skuId": "sku_ketchup20",  "expectedQuantity": 300 }
    ]
  }'
Response — 201 Created (the PO header; lines come from GET /:id)
{
  "success": true,
  "data": {
    "id": "inb_9f2a...",
    "shipmentNumber": "RCV-20260629-00031",
    "externalPoNumber": "PO-2026-00184",
    "status": "PENDING",
    "vendorName": "Sunbelt Co-Packers",
    "expectedArrival": "2026-07-05T00:00:00.000Z",
    "lineCount": 2,
    "totalExpected": 540,
    "totalReceived": 0,
    "totalDamaged": 0,
    "totalRejected": 0
  }
}

The PO is recorded at the warehouse warehouseId names, else the one your X-Warehouse-Id header names, else your client's default warehouse. With none of those, only a tenant with a single warehouse can leave the warehouse out; otherwise the create is a 400 (Multiple warehouses are active — specify warehouseId on the shipment, set your session warehouse, or set the client's default warehouse.).

Re-creating a PO with an externalPoNumber that already exists returns the existing PO. Retrieve by external PO number via the list filter, and by internal id via the path — the detail carries the lines[] with per-line receivedQuantity, damagedQuantity, status and receipts:

Look up by your PO number, or by internal id
curl "https://api.staging.canopywms.com/api/receiving/shipments?externalPo=PO-2026-00184" -H "Authorization: Bearer $ACCESS_TOKEN"
curl "https://api.staging.canopywms.com/api/receiving/shipments/inb_9f2a..."          -H "Authorization: Bearer $ACCESS_TOKEN"

As the warehouse receives the PO, the retrieved record reflects progress: status moves PENDING → IN_PROGRESS → COMPLETED, and totalReceived / per-line receivedQuantity fill in. A purchase_order.received webhook fires when receiving completes (see Webhooks). A problem the receiver reported on a line while receiving (a receiving note that was broken, or another reason, with the units and any photos) is a PROBLEM_REPORTED entry in the PO's history; it changes no quantity.

While a PO is open (PENDING or IN_PROGRESS), each line's status compares what has come in (good plus damaged units) with its expectedQuantity: PENDING with nothing yet, PARTIAL below expected, COMPLETE at it, OVER above it. Changing expectedQuantity with PATCH /api/receiving/shipments/:id, or adding units to an existing line through addLines, recomputes the status in the same write. Lowering it below what already came in is allowed and makes the line OVER.

Completing the PO settles every line for good: SHORT if less came in than expected (nothing at all included), COMPLETE if exactly that, OVER if more, and OVER for a line that wasn't on the PO (an unexpected item).

Each list row also reports totalExpected, totalReceived, totalDamaged and totalRejected, and the available filters are externalPo, shipmentNumber, status, vendorName, dateFrom/dateTo, updatedSince, and search. The list sorts by createdAt (newest first) unless sortBy names one of its own columns — updatedAt is not one of them, so see Conventions → Filtering & incremental sync before delta-walking POs. Putaway locations (updateLines[].binId) are warehouse-staff only and are rejected with 403 for API credentials.

Changing a line's expectedQuantity on PATCH re-judges that line's status against the units already received and rejected, in the same write. Lower it below what came in and the line reads OVER. Cancel is refused (400) once an in-progress PO has any unit received or rejected: Cannot cancel a shipment with received or rejected items. Complete it, or unreceive them first.

Sell ahead

If your client is opted in to sell ahead, a PO line can advertise units to the client's sales channels before the goods arrive; orders for those units wait as backorders until the PO is received. Send lines[].sellAheadQuantity on create, or addLines[].sellAheadQuantity / updateLines[].sellAheadQuantity on PATCH; the detail's lines[] report the current sellAheadQuantity.

The value must satisfy 0 ≤ n ≤ that line's expectedQuantity (Sell ahead cannot exceed the expected quantity otherwise), and any value above 0 is rejected unless sell ahead is enabled for the client. Lowering expectedQuantity below the current sell-ahead clamps it, and every sell-ahead change appears in the PO history.

Orders

Ingest orders, manage their lifecycle (hold / release / cancel), and retrieve them by your order number or sync them incrementally.

OperationMethod & pathScope
Create / ingest orderPOST /api/ordersorders:write
List / search ordersGET /api/ordersorders:read
Retrieve orderGET /api/orders/:idorders:read
Update orderPATCH /api/orders/:idorders:write
Hold / releasePATCH /api/orders/:id/hold · /releaseorders:write
List an order's holdsGET /api/orders/:id/holdsorders:read
Resolve one holdPOST /api/orders/:id/holds/:holdId/resolveorders:write
Cancel / requeuePATCH /api/orders/:id/cancel · /requeueorders:write
Preview / execute a reshipGET /api/orders/:id/reship/preview · POST /api/orders/:id/reshiporders:write · tenant-wide only
Override carrier / servicePATCH /api/orders/:id/postage-overrideshipping:write · tenant-wide only
Mark shipped / deliveredPOST /api/orders/:id/mark-shipped · /mark-deliveredorders:write · tenant-wide only
Add / update / remove linePOST·PATCH·DELETE /api/orders/:id/lines/...orders:write
Allocate / re-run automation rulesPOST /api/orders/:id/allocate · /reprocess-rulesorders:write · tenant-wide only
Order historyGET /api/orders/:id/historyorders:read
Who handled it, where it isGET /api/orders/:id/handlingorders:read · tenant-wide only
NotesGET·POST /api/orders/:id/notesorders:read · orders:write
Blocked orders by SKUGET /api/orders/blockedorders:read
Diagnose a missing orderGET /api/orders/diagnose-missingorders:read

Before reshipping, preview the same inputs and check blocked. A relabel can buy replacement postage. For a carrier override, choose an exact service token from GET /api/shipping/services (shipping:read). Reship (preview and execute) and the carrier override are the 3PL's calls: a client-pinned credential is refused with 403 (This action is restricted to warehouse staff), even when it holds the scope. A tenant-wide credential gets through.

A postage override is a service token and the carrier account it is bought on. PATCH /api/orders/:id/postage-override takes postageType with an optional carrierAccountId: the carrierAccountId of the group in GET /api/shipping/services that lists the token, read with the order's client as X-Client-Id (without it every group's id is null). Omit carrierAccountId, or send null, and the label buys on the client's default account. It never travels alone: carrierAccountId without postageType is refused (400, Validation error), because an account on its own would pair with a lower layer's token. An account the order's client cannot ship on, another client's or one its carrier sourcing excludes, is refused (400): Carrier account acct_7d2e… is not one this client can ship on. Choose an account from the list before saving. So is a token that account does not offer (400, Service "…" is not offered by carrier account "…" (…)…). Generic Label always saves with a null account, and postageType: null clears both.

Override the service and the account it buys on
curl -X PATCH https://api.staging.canopywms.com/api/orders/ord_9a8b.../postage-override \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "postageType": "USPS Ground Advantage", "carrierAccountId": "acct_7d2e..." }'
Response
{
  "success": true,
  "data": {
    "id": "ord_9a8b...",
    "orderNumber": "CC-10042",
    "status": "ALLOCATED",
    "priority": "STANDARD",
    "manualPostageType": "USPS Ground Advantage",
    "manualPostageAccountId": "acct_7d2e...",
    "manualServiceOptions": null
  }
}

Every order row, list row and detail carries both pairs back: manualPostageType with manualPostageAccountId, and, for a Set Postage Type automation rule, postageTypeOverride with postageTypeOverrideAccountId. null in either account field means the client's default account; an override saved before accounts were recorded reads that way. A label bought with neither a postageType nor a carrierAccountId of its own (POST /api/shipping/labels) uses the saved pair, and is refused (400) rather than falling back when the saved account has since been removed, deactivated or excluded by the client's carrier sourcing: The manual postage override for this order names carrier account "…", which has been deactivated. Update the manual postage override before buying.

Holding and releasing record who asked. A client-pinned credential holds as its own client (holdSource: CLIENT) and may resolve only its own client's hold (CLIENT_HOLD) and a note that isn't a warehouse decision; any other row is refused with 403 (The warehouse decides this hold — contact your 3PL to release it). A tenant-wide credential holds as the warehouse (holdSource: OPERATOR) and may release any hold.

An order can carry several holds at once, and notes beside them. Each is a row. GET /api/orders/:id/holds returns them as an array in data, open rows first, then newest first. A row carries:

FieldMeaning
kindWhat the row is: OPERATOR_HOLD, CLIENT_HOLD, CHANNEL_HOLD, PAYMENT_HOLD, RULE_HOLD, or one of the kinds Canopy places when a person has to decide (UNKNOWN_SKU, ADDRESS_INVALID, …). The schema lists every value.
blockingtrue for a hold, false for a note. A note never changes the order's status.
sourceWho placed it: OPERATOR, CLIENT, CHANNEL, PAYMENT or SYSTEM. It decides who may resolve it.
messageThe reason, as people read it.
releaseAtA timed hold's deadline, or null. When it passes, Canopy resolves this row.
placedAt, resolvedAt, resolutionWhen it was placed; when and how it was closed, null while open.

The order is ON_HOLD while any open row is blocking. Its holdReason and holdSource copy the newest open blocking row. An order outside your scope answers 404 (Order not found).

POST /api/orders/:id/holds/:holdId/resolve closes one row. Its body says how, as { "resolution": "…" }; an empty body means RELEASED. A note (blocking: false) takes ACKNOWLEDGED, whatever its kind. A hold takes the choices of its kind, and most take only RELEASED, which lifts it. KEPT keeps the order as it stands, without the change the row is about. APPLIED makes the sales channel's change, as Apply channel's changes does on the order screen. The kinds that take something else:

KindChoices
LINES_ADDED_NEED_PICK, CHANGE_AFTER_PACKINGKEPT. Picking the added units needs the bins they come from, so that is the warehouse's Resolve on the order screen; QUICK_PICKED sent here is refused.
STAFF_EDIT_CONFLICTAPPLIED or KEPT
CHANNEL_EDIT_FAILED, UNKNOWN_SKUAPPLIED or RELEASED
BOX_MAY_HAVE_LEFT, CANCEL_BLOCKED, VOIDED_LABEL_IN_TRANSITRELEASED or KEPT
MERGED_CHILD_HELD, MERGED_CHILD_PAYMENTACKNOWLEDGED, which ships the merged order as packed, or RELEASED
The other MERGED_CHILD_… kindsACKNOWLEDGED

A row whose payload.reason names a change other than the channel's line edit, such as a fulfillment-order move Canopy could not follow, does not take APPLIED. Any other choice is refused with 400, and the message lists the ones the row takes.

The response is the order as the call leaves it, with two more fields. released is true once the order is off hold. stillHeldFor lists the kinds of the open blocking rows still holding it. Releasing one hold while another is open leaves the order ON_HOLD, with released: false. The order's sales channel can also put its own hold back as the last one lifts; the answer then reads released: false with stillHeldFor: ["CHANNEL_HOLD"]. A balance the channel still reports holds it the same way, with stillHeldFor: ["PAYMENT_HOLD"]. APPLIED adds applied, how the change went: applied, flagged, empty or skipped. A review note, one with a payload.reason, answers like a hold: released is true when no blocking row is open, and stillHeldFor lists any that are. Any other note answers released: false and an empty stillHeldFor whatever else holds the order, so read status for where it stands. A row that is already closed is left as it is. The whole-order PATCH /api/orders/:id/release lifts every hold a person or Canopy placed in one call (for a client-pinned credential, only its own CLIENT rows); a CHANNEL or PAYMENT hold beside them stays open.

Resolve one hold
curl -X POST https://api.staging.canopywms.com/api/orders/ord_9a8b.../holds/hold_5c1e.../resolve \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: 3b7d...-resolve-hold_5c1e" \
  -H "Content-Type: application/json" \
  -d '{ "resolution": "RELEASED" }'
Response: the order is still held for another reason
{
  "success": true,
  "data": {
    "id": "ord_9a8b...",
    "orderNumber": "CC-10042",
    "status": "ON_HOLD",
    "holdReason": "Fraud review",
    "holdSource": "OPERATOR",
    "released": false,
    "stillHeldFor": ["OPERATOR_HOLD"]
  }
}

The call is refused with:

StatusErrorWhen
403The warehouse decides this hold — contact your 3PL to release itA client-pinned credential resolving a row that isn't its own client's CLIENT_HOLD, or a note that is a warehouse decision rather than a plain read. A tenant-wide credential may resolve any row.
409Someone already decided this hold — the order stays as they left itThe row already closed with a different resolution before this call landed. Sending the resolution it already closed with is a no-op, not an error.

| 404 | Hold not found | No such row on this order in your scope. A row of another order is not found from this order's path. | | 409 | Can't release this hold — … | Releasing the last hold while the shipping address has a country no carrier serves. Fix the address first. | | 409 | Order … changed concurrently (expected …, was …) — retry | The order changed while the call ran. Re-read it and retry. | | 400 | … resolves by … — not … | A resolution the row's kind doesn't take. The message names the ones it does. | | 400 | Picking the added units needs the bins they come from — … | QUICK_PICKED. Picking is done on the order screen. | | 400 | Validation error | A resolution outside RELEASED, ACKNOWLEDGED, KEPT, APPLIED and QUICK_PICKED. |

Ingest an order
curl -X POST https://api.staging.canopywms.com/api/orders \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: 7a2c...-order-CC-10042" \
  -H "Content-Type: application/json" \
  -d '{
    "orderNumber": "CC-10042",
    "warehouseId": "wh_austin",
    "priority": "STANDARD",
    "customerName": "Emma Thompson",
    "customerEmail": "emma.thompson@example.com",
    "shippingMethod": "Standard",
    "shippingAddress": { "name": "Emma Thompson", "line1": "210 Congress Ave", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" },
    "lines": [ { "skuId": "sku_sriracha17", "quantity": 2, "unitPrice": 6.49 } ]
  }'

Which warehouse the order lands in

Omit warehouseId. Your 3PL sets a default warehouse on your client, and orders that name none are created there (a 3PL with a single warehouse needs no default at all). A warehouse routing rule your 3PL has set up for your client outranks the default. It can send the order to another of their warehouses — for example the first one whose stock can fill it — and when none can, it parks the order at one of the rule's warehouses to wait for stock, or holds it (ON_HOLD, with a holdReason that begins No warehouse can fill this order). The X-Warehouse-Id header plays no part. Read the default back once with GET /api/clients/lookup — your own client row carries defaultWarehouseId — and if it is null on a multi-warehouse 3PL, ask them to set it before going live; the create is rejected when no warehouse can be resolved. warehouseId is the 3PL's to send: a tenant-wide credential (or staff) can pin an order to a warehouse with an id from GET /api/warehouse/warehouses, while a client-pinned credential sending it is refused with 403 — "Warehouse is chosen by routing; contact your fulfillment provider".

Until pick work starts, the rules can move the order again. Editing what they read — the shipping address, tags, shipping method, priority or lines, through PATCH or the line endpoints — runs them again, and the order can move to another warehouse a rule now chooses. Stock arriving runs them again too: the No warehouse can fill this order hold can lift, and where your 3PL has switched on Re-route backorders for the rule, a backordered order, or one on that hold, can move to a warehouse that now has stock for it. Stock never moves a member of a live Bulk Ship batch, which ships from its batch's warehouse. A part of a split order waits for its own warehouse's stock: only a rule that splits, with Re-route backorders on, moves a waiting line of it. A split order is routed again only as one customer order (see Routing a split order). A rule that splits orders across warehouses needs only one line's stock to be covered: that line alone can move to a warehouse that has stock for it, even while the rest of the order stays where it is. On a Shopify order, units your store hands to Canopy at a location that feeds another warehouse can move it as well: to the warehouse a rule now picks or, with no rule matching, to the one that location feeds (warehouseSource then reads CHANNEL_LOCATION). None of these touches a pinned order — one whose warehouse your 3PL named with warehouseId, or its staff chose — but POST /api/orders/:id/reprocess-rules can move even that one. It cannot move a batch member: the re-run still answers 200, applies its other actions, and lists the move under skipped as SET_WAREHOUSE with the reason (the order is in Bulk Ship batch … — remove it from the batch to re-route).

Read back where the order ships from and why: list rows and the detail carry warehouseName, and the detail adds warehouseSource, the step that chose the warehouse: EXPLICIT (named at creation by your 3PL's warehouseId), RULE, CHANNEL_LOCATION, CHANNEL_DEFAULT, CLIENT_DEFAULT, ONLY_WAREHOUSE, OPERATOR (moved by your 3PL's staff), or CHANNEL_MOVE (legacy only; no order is given this value any more, though an older order can still carry it). It is null on orders created before Canopy recorded it, and such an order isn't pinned, even if you named its warehouse. When it is RULE, routingRule (id, name) names the rule. A merchant's move in Shopify that Canopy follows is also recorded this way: RULE when a Follow channel rule placed the order and still does, CHANNEL_LOCATION otherwise.

Order number

orderNumber is unique per client: a repeat is refused with 409 and existingOrderId (the order that already has it), whatever the body says, so rely on the Idempotency-Key header for safe retries rather than on the order number.

Four optional fields set the order's own split policy: allowPartialShip (may it ship in parts), partialShipMinUnits and partialShipMinValue (what its in-stock part must reach before it does) and partialShipDwellHours (how long it must have been short first, up to 720). Omit them to inherit the client's defaults; 0 means none on this order, even when the client's default is higher.

The create response is the stored order before allocation runs. Orders auto-allocate moments after ingest, so read the order back for its allocated state. A fully reserved order reads ALLOCATED or REPLENISH: both mean every line is reserved, and REPLENISH says the reserved units are not in pickable locations yet and need an internal replenishment move before the order can be picked. The status flips between the two on its own as stock moves, so treat REPLENISH as a reserved, pre-pick state — the same edits, holds and cancellations apply as for ALLOCATED. An order your 3PL has merged into another reads MERGED: it is parked, its units ship in the other order's box, and it turns SHIPPED when that box goes out; meanwhile only its tags and notes accept edits. Retrieve by your order number, filter by status, or pull everything changed since your last sync:

curl "https://api.staging.canopywms.com/api/orders?orderNumber=CC-10042" -H "Authorization: Bearer $ACCESS_TOKEN"
curl "https://api.staging.canopywms.com/api/orders?status=SHIPPED&updatedSince=2026-06-29T00:00:00Z&sortBy=updatedAt&sortOrder=asc" -H "Authorization: Bearer $ACCESS_TOKEN"

?warehouseIds= narrows the list to orders shipping from those warehouses (comma-separated ids, at most 50). It replaces X-Warehouse-Id when you send both, and, unlike the header, an unknown id isn't ignored — it matches no orders.

The retrieved order exposes its status, lines (with quantityAllocated/quantityPicked/quantityPacked), tracking events, and its edit history. An order.created webhook fires on ingest and order.shipped when it ships.

What can change, and when

Which fields PATCH accepts depends on the order's status: everything before allocation; address, notes and tags while it is being picked and packed; notes and tags once shipped (and on a MERGED order). Line quantity and price can both be edited before allocation (PENDING or ON_HOLD); once the order is ALLOCATED only a quantity decrease is accepted — no increase, no price change — and the line must still owe at least one unit afterwards. Wholesale orders refuse it, and on a PICKING or PICKED order an API token gets 403: a reduction there retires pick work, which only warehouse staff can do, from the order in Canopy. Adding and removing lines is allowed until picking starts (PENDING, ON_HOLD, ALLOCATED, REPLENISH, BACKORDERED, UNFULFILLABLE) and again on a resting PARTIALLY_SHIPPED order, whose owed remainder is pre-pick work. A remove is refused (400) while the order sits on an in-flight pick list, held or not: This order is on an in-flight pick list — wait for picking to finish (or abandon the pick from the Short Picks page) before editing lines. The same refusal answers any line add or edit once a picker has units of the order in a tote.

A field that is not editable in the current status returns 400; an edit that races a warehouse action returns 409 — re-read the order and retry. Orders that originate from a connected channel (e.g. Shopify) also carry the channel's order id, queryable via ?externalOrderId=. ?partial=true narrows the list to orders in a partial-shipment cycle — a resting PARTIALLY_SHIPPED order with units still owed, an order being picked or packed that is carrying owed units this cycle will not serve, or an order back in a pre-pick status after one parcel has already gone; only true filters, and partial=false returns the unfiltered list.

Line edits on an order split across warehouses

An order can split across more than one of your 3PL's warehouses (see Orders → split orders). On a split order, a line add, update, or remove applies to the customer order as a whole: every warehouse order carries the line at the same quantity, not just the copy named in the path. Line responses add settledInFamily, the lowest quantity the line can be reduced to, since it already shipped or is open outside Canopy. A quantity below that is a 400.

When the change can only land on a warehouse order already being worked, it is held for review on every order of the split instead of applied, and nothing changes. POST .../lines returns 200 with { heldForReview } instead of 201. PATCH and DELETE .../lines/:lineId add heldForReview alongside the line, and on DELETE set success: false, rather than failing the call. heldForReview.message explains why, and heldBy names the warehouse orders holding up the change.

Split orders

Your 3PL can split one customer order across two or more of their warehouses before pick. No API call does this. Each warehouse's part is then its own order, and every one of them carries the whole order's lines and amounts. Read a split back from these fields:

  • splitFromOrderId, on every order shape: the parent order's id when this order is one warehouse's part of a split customer order, null otherwise. The parent keeps the channel order number.
  • splitFrom (id, orderNumber), on list rows and the detail: the parent, or null.
  • splitSiblings[], on the detail: every other order of the same customer order, the parent first, each with id, orderNumber, status, warehouseId and warehouseName. Empty when nothing is split.

Count customer orders once: group by splitFromOrderId ?? id. Shipment rows carry the same link as orderSplitFrom, and the shipments summary already counts a split customer order once.

A split order is cancelled whole. PATCH /api/orders/:id/cancel on any part cancels every warehouse's order of it, and the order in the response carries a customerOrder summary of the parts. Once any part has shipped, the call answers 400 instead: CC-… is split across warehouses and CC-… has shipped — cancelling it would leave those units with the carrier. Cancel the remainder instead: … POST /api/orders/:id/cancel-remainder is for your 3PL's staff only; an API token can't call it. A split is routed as one customer order (below). An order.created webhook fires for each new sibling, and the order events carry splitFromOrderId and splitFromOrderNumber (see Webhooks).

Routing a split order

The routing rules decide for the whole customer order, not for each part. An edit to what they read, on any part (the same PATCH and line edits as above), runs them once for the family: they judge the edited part's header over every part's units. Pick work starting on any part freezes the family where it is. What the matching rule says decides:

  • A rule that chooses one warehouse folds the family there: the parent ships every unit from there, moving there if it isn't and taking over the units of any part already there, and every other part is retired (CANCELLED).
  • No rule matching folds it to the channel's default warehouse, else the client's, if that warehouse can ship on the order's channel. With neither, it stays as Shopify has it.
  • A Follow channel rule keeps the split as Shopify has it.
  • A rule that splits redistributes the units as its plan says, making, topping up or retiring parts.
  • A hold or a backorder outcome leaves the family where it is.

A family is pinned when any live part of it is: every split your 3PL's staff make is. The detail's placementPinned is true then, on every part. An edit never moves a pinned family's existing units; units it takes on afterwards, such as a channel edit adding units, alone go where the rules say for the customer order.

POST /api/orders/:id/reprocess-rules on any part runs the same decision, pinned family included, and the parts it re-places stop being pinned. The response's applied lists SET_WAREHOUSE with a detail of <warehouse> — <reason>; the customer order's warehouses folded into it, kept as Shopify split it — <reason>; every warehouse's order is the rule's now, or split across <warehouse> and <warehouse> — <reason>. A family left where it is lists the move under skipped with the reason, such as order CC-… at … is being worked (…) — the family's placement is frozen. Called on a part the fold retires, the response's status is CANCELLED and the rest of the rules don't run on it.

Shipments

Buy carrier labels and retrieve shipment identifiers, tracking, carrier/service, and the actual historical label cost.

OperationMethod & pathScope
Create labelPOST /api/shipping/labelsshipping:write · tenant-wide only
Available servicesGET /api/shipping/servicesshipping:read
List shipmentsGET /api/shipping/shipmentsshipping:read
Shipments summaryGET /api/shipping/shipments/summaryshipping:read
Delayed shipments, by attention bucketGET /api/shipping/shipments/attentionshipping:read
Retrieve shipment (+ tracking)GET /api/shipping/shipments/:idshipping:read
Live carrier trackingGET /api/shipping/shipments/:id/trackshipping:read
Update tracking on a generic labelPATCH /api/shipping/shipments/:id/trackingshipping:write · tenant-wide only
Void labelPOST /api/shipping/shipments/:id/voidshipping:write · tenant-wide only
Shipped items (per-SKU)GET /api/shipping/shipped-items · /summaryshipping:read

GET /api/shipping/shipments and GET /api/shipping/shipments/summary filter by dateFrom/dateTo as calendar days. Two more parameters take an exact timestamp instead: shippedFrom is inclusive, shippedBefore is exclusive, and neither expands to the start or end of a day. Both must be full RFC 3339 datetimes with a Z or a numeric offset. Use them where a report needs a precise boundary. Shipment rows also carry orderSplitFrom when the parcel's order is part of a split order.

The postageType you send to POST /api/shipping/labels is checked against the carrier account the order resolves to before any postage is bought. A token that account does not offer is refused (400): Service "…" is not offered by carrier account "…" (…). Update the shipping method mapping or the order's carrier override before buying. Pick tokens from GET /api/shipping/services. fromLiveQuote: true skips the check; it is meant for a rate just quoted on that account inside Canopy, not for a saved choice. So does naming carrierAccountId: the token is then taken as a rate that account quoted.

POST /api/shipping/labels answers 409 for an order that shipped inside a merged order's box — Order CC-… shipped in merged order CC-…'s box — reship or relabel CC-… instead. Such an order owns no parcel: the label, like a reship or relabel, belongs to the order it shipped in.

POST /api/shipping/shipments/:id/void answers 409 when an open re-send is replacing the box — the caller's own order's, or a split sibling's re-send taking units off it — with A re-send is open for this box — cancel the re-send first if the box is going out after all, or let the re-send go out. This check doesn't apply to a void made for an order cancellation, a relabel, or a Bulk Ship batch void.

GET /api/shipping/shipments/:id — response (abridged)
{
  "success": true,
  "data": {
    "id": "shp_3c1b...",
    "shipCode": "SHP-10231",
    "orderId": "ord_9a8b...",
    "orderNumber": "CC-10042",
    "trackingNumber": "1Z999AA10123456784",
    "trackingUrl": null,
    "carrierService": "UPS Ground",
    "canEditTracking": false,
    "canMarkShipped": false,
    "shippingCost": 8.42,
    "rate": 8.02,
    "tax": 0.40,
    "totalPaid": 8.42,
    "weight": 52,
    "shipDate": "2026-06-28T15:10:00.000Z",
    "status": "IN_TRANSIT",
    "deliveredAt": null,
    "lines": [ { "orderLineId": "line_1", "skuId": "sku_sriracha17", "quantity": 2 } ],
    "tracking": {
      "status": "IN_TRANSIT",
      "source": "STORED",
      "events": [ { "status": "IN_TRANSIT", "description": "Departed facility", "timestamp": "2026-06-28T22:01:00Z" } ]
    }
  }
}

The GET /api/shipping/shipped-items report breaks shipping down per SKU, exposing quantityOrdered, quantityShipped, trackingNumber, carrierService, and the actual shippingCost per shipment — useful for reconciling outbound volume and freight spend.

Order-money fields on shipment rows (orderTotalAmount, orderShippingAmount, line prices, customer contact details) are returned as null unless the credential also holds orders:read.

Attention buckets

GET /api/shipping/shipments/attention counts in-flight parcels that have stopped moving, in five buckets: AWAITING_SCAN (label bought, no custody scan in 72 h), STALLED (in transit, no scan for 4 days), EXCEPTION (the carrier reported a problem), NOT_RECENTLY_CHECKED (the tracking poller is behind, so the silence may be ours), and RETURNED_TO_SENDER (the carrier sent the parcel back and no RTS return has been opened for it). The first four are mutually exclusive slices of inFlight and exclude return labels; RETURNED_TO_SENDER overlaps them — it is a to-do, not a status. Pass a bucket name as ?attention= on GET /api/shipping/shipments to get the rows behind a count.

Generic (carrier-less) labels

GET /api/shipping/services lists the carrier services plus one internal option, Generic Label (token: "Generic Label", trackable: false). Pass a service token as postageType on POST /api/shipping/labels; with the Generic Label token, Canopy generates the label PDF itself and buys no carrier label — no carrier is contacted.

A generic shipment behaves differently on the tracking surface:

  • GET /api/shipping/shipments/:id returns tracking: null, and GET /api/shipping/shipments/:id/track returns status: "UNKNOWN", the stored shipment status as rawStatus, and no events — neither contacts a carrier.
  • Every shipment carries canEditTracking (true for an active, outbound Generic Label parcel whose pack is completed) and canMarkShipped (that, the order is waiting at FULFILLED, and this parcel still holds its say over the order's status — no carrier scan will ever ship it).
  • PATCH /api/shipping/shipments/:id/tracking adds, corrects, or clears tracking on such a parcel. Send the new trackingNumber and trackingUrl (http/https, or null) together with the values you last read as expectedTrackingNumber / expectedTrackingUrl; if they no longer match, someone changed it first and the update returns 409. Clear tracking with an empty trackingNumber and trackingUrl: null — but on an order that came from a sales channel, a number already attached can't be removed (409), and can be corrected only where the channel updates an existing fulfillment (409 otherwise). A new, non-empty trackingNumber is also refused with 409 on a cancelled order (nothing to track), and on an order waiting on an "is the box still here?" hold — settle that hold on the order first, then attach the tracking.
  • No postage is bought and no carrier is contacted, but the write is not always local. On a sales channel order, a new non-empty trackingNumber queues a push that creates — or, where the channel supports it, corrects — the fulfillment on the storefront, which by default emails the customer a shipping confirmation. A URL-only edit, a re-save of the same number, and clearing tracking never push. If the order is waiting at FULFILLED, saving a new number also advances it to SHIPPED — on any order, channel or not — which emits the order.shipped webhook and, on a sales channel with them switched on, queues archive-on-ship and capture-on-ship (both off by default; capture charges the customer). A parcel that has lost its say over the order's status — superseded by a reship, or retired when a partial shipment closed out — records its tracking without shipping the order.

Inventory

Read stock levels by SKU and by location, and explain movements.

OperationMethod & pathScope
List SKUs with stockGET /api/inventory/skusinventory:read
Retrieve a SKUGET /api/inventory/skus/:idinventory:read
Stock by locationGET /api/inventory/stockinventory:read · tenant-wide only
Why a SKU's stock movedGET /api/inventory/stock-changesinventory:read · tenant-wide only

GET /api/inventory/skus returns each SKU's totalStock, allocated, available, backordered, locationCount (not for a client-pinned credential), dailyVelocity, and daysOfStock. GET /api/inventory/stock returns one row per bin — binLabel, zoneName, warehouseName, quantity, isPickable, lotNumber, and expiresAt. Neither list supports updatedSince; poll them whole.

Returns

Create and read returns (RMAs) and their lines.

OperationMethod & pathScope
List returnsGET /api/returnsreturns:read
Retrieve a returnGET /api/returns/:idreturns:read
List a client's return reasonsGET /api/clients/:id/return-codesreturns:read
Create a returnPOST /api/returnsreturns:write · tenant-wide only

Creating an RMA requires a client-specific reason code for each line, either on the line or as the RMA-level default. Use a tenant-wide credential with X-Client-Id, and send an Idempotency-Key for retries. Creation records the expected return. It does not buy a label, receive goods, or issue a refund.

The list filters on status, type (CUSTOMER — the shopper sent it back; RTS — the carrier returned the parcel; BLIND — a return with no source order in Canopy), missingTracking=true (RMAs with no tracking logged at all: the "customer said they'd ship it" backlog), orderId, reasonCodeId, dateFrom / dateTo, and search. It does not support updatedSince.

Each return exposes its rmaNumber, status, type, linked orderId / orderNumber (both null on a blind return, which is never refunded), reasonCode, labelType, its tracking (trackingNumber, carrierService, returnShipment), and its refund state — refundStatus (NOT_REQUESTED / READY / ISSUING / ISSUED / FAILED), refundAmount, refundIssuedAt. An RTS return also carries the parcel the carrier sent back as outboundShipmentId and outboundShipment (id, trackingNumber, carrierService, status), and settlement is RESEND_AS_IS when that parcel was found intact and settled with no receipt and no stock move (otherwise null). The detail adds the lines with per-line status. return.completed and return.cancelled webhooks fire on those transitions.

Clients

Resolve client (brand) names to ids for X-Client-Id scoping, read account health, and read or maintain the operating policy automated callers follow.

OperationMethod & pathScope
Find clients by name (with default warehouse)GET /api/clients/lookupclients:read
List warehousesGET /api/warehouse/warehousesclients:read
Week-over-week account healthGET /api/clients/:id/healthorders:read
Agent operating policyGET·PUT /api/clients/:id/agent-policypolicy:read · policy:write · tenant-wide only

A client-pinned credential only ever sees its own client; a tenant-wide credential resolves a name with lookup and passes the id as X-Client-Id on other calls.

Billing

Read invoices, charges, adjustments and billing periods, and the rate cards that price them. Billing is read-only here: there is no billing write scope and no billing webhook event yet.

OperationMethod & pathScope
List invoicesGET /api/billing/invoicesbilling:read
Retrieve an invoiceGET /api/billing/invoices/:idbilling:read
Refresh an invoice's signed download URLsGET /api/billing/invoices/:id/signed-urlsbilling:read
Download an invoice (CSV or PDF)GET /api/billing/invoices/:id/download?format=csv · pdfbilling:read
List billable chargesGET /api/billing/chargesbilling:read
List charge adjustmentsGET /api/billing/adjustmentsbilling:read
List billing periodsGET /api/billing/periodsbilling:read
Retrieve a billing periodGET /api/billing/periods/:idbilling:read
List rate cardsGET /api/billing/rate-cardsrate-cards:read
Retrieve a rate card (with its rules)GET /api/billing/rate-cards/:idrate-cards:read
A rate card's version chainGET /api/billing/rate-cards/:id/versionsrate-cards:read
A rule's pricing historyGET /api/billing/rate-cards/:id/rules/:ruleId/historyrate-cards:read

Invoices, charges and periods come back newest first, rate cards newest effective date first. Charges, adjustments, periods and rate cards are a bare array in data with no pagination. GET /api/billing/invoices paginates only when you send page (limit then defaults to 25, max 250, and a pagination block comes back beside data); without page it returns every invoice in scope as a bare array. Filter invoices by billingPeriodId, sent (ALL, SENT, UNSENT) and search (invoice number); charges by billingPeriodId, category, sourceType and status; adjustments by billingPeriodId, category and appliesToId; periods by status. A tenant-wide credential narrows any of them with clientId or the X-Client-Id header.

A billing period moves OPEN → CLOSING → FINALIZED → INVOICED as it closes out; the list and the detail both report its running chargesTotal beside its status. Adjustment amounts are signed: a negative adjustment is a credit.

A client-pinned credential sees only its own client's invoices, charges, adjustments and periods, in the shape that client's portal login sees: clientId and X-Client-Id are ignored, bin-storage charge lines read Storage fee and name no bin, and the staff-only fields a tenant-wide credential also receives (each row schema's description lists them) are absent. It gets null for the signed URLs on an invoice and from /signed-urls; use /download instead, which re-renders the invoice on demand and streams it as a Content-Disposition: attachment of text/csv or application/pdf. Object-storage keys are sent to no API credential at all.

Rate cards are tenant-wide only

rate-cards:read opens the rate card endpoints to a tenant-wide credential only. A client-pinned credential is refused with 403, the same as a client login opening the billing tree in the app. Drafts (isActive: false) are listed; deleted cards are not.

On this page