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:
| Header | Effect |
|---|---|
X-Client-Id | Scope 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-Id | Narrow 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.
| Operation | Method & path | Scope |
|---|---|---|
| Create PO | POST /api/receiving/shipments | receiving:write |
| List / search POs | GET /api/receiving/shipments | receiving:read |
| Retrieve PO (with lines) | GET /api/receiving/shipments/:id | receiving:read |
| Update PO | PATCH /api/receiving/shipments/:id | receiving:write |
| Cancel PO | POST /api/receiving/shipments/:id/cancel | receiving:write |
| PO history | GET /api/receiving/shipments/:id/history | receiving:read |
| Overdue POs and discrepancies | GET /api/receiving/shipments/attention | receiving:read |
| Received, not yet put away | GET /api/receiving/putaway-queue | receiving:read · tenant-wide only |
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 }
]
}'{
"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:
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.
| Operation | Method & path | Scope |
|---|---|---|
| Create / ingest order | POST /api/orders | orders:write |
| List / search orders | GET /api/orders | orders:read |
| Retrieve order | GET /api/orders/:id | orders:read |
| Update order | PATCH /api/orders/:id | orders:write |
| Hold / release | PATCH /api/orders/:id/hold · /release | orders:write |
| List an order's holds | GET /api/orders/:id/holds | orders:read |
| Resolve one hold | POST /api/orders/:id/holds/:holdId/resolve | orders:write |
| Cancel / requeue | PATCH /api/orders/:id/cancel · /requeue | orders:write |
| Preview / execute a reship | GET /api/orders/:id/reship/preview · POST /api/orders/:id/reship | orders:write · tenant-wide only |
| Override carrier / service | PATCH /api/orders/:id/postage-override | shipping:write · tenant-wide only |
| Mark shipped / delivered | POST /api/orders/:id/mark-shipped · /mark-delivered | orders:write · tenant-wide only |
| Add / update / remove line | POST·PATCH·DELETE /api/orders/:id/lines/... | orders:write |
| Allocate / re-run automation rules | POST /api/orders/:id/allocate · /reprocess-rules | orders:write · tenant-wide only |
| Order history | GET /api/orders/:id/history | orders:read |
| Who handled it, where it is | GET /api/orders/:id/handling | orders:read · tenant-wide only |
| Notes | GET·POST /api/orders/:id/notes | orders:read · orders:write |
| Blocked orders by SKU | GET /api/orders/blocked | orders:read |
| Diagnose a missing order | GET /api/orders/diagnose-missing | orders: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.
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..." }'{
"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:
| Field | Meaning |
|---|---|
kind | What 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. |
blocking | true for a hold, false for a note. A note never changes the order's status. |
source | Who placed it: OPERATOR, CLIENT, CHANNEL, PAYMENT or SYSTEM. It decides who may resolve it. |
message | The reason, as people read it. |
releaseAt | A timed hold's deadline, or null. When it passes, Canopy resolves this row. |
placedAt, resolvedAt, resolution | When 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:
| Kind | Choices |
|---|---|
LINES_ADDED_NEED_PICK, CHANGE_AFTER_PACKING | KEPT. 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_CONFLICT | APPLIED or KEPT |
CHANNEL_EDIT_FAILED, UNKNOWN_SKU | APPLIED or RELEASED |
BOX_MAY_HAVE_LEFT, CANCEL_BLOCKED, VOIDED_LABEL_IN_TRANSIT | RELEASED or KEPT |
MERGED_CHILD_HELD, MERGED_CHILD_PAYMENT | ACKNOWLEDGED, which ships the merged order as packed, or RELEASED |
The other MERGED_CHILD_… kinds | ACKNOWLEDGED |
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.
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" }'{
"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:
| Status | Error | When |
|---|---|---|
403 | The warehouse decides this hold — contact your 3PL to release it | A 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. |
409 | Someone already decided this hold — the order stays as they left it | The 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. |
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,nullotherwise. The parent keeps the channel order number.splitFrom(id,orderNumber), on list rows and the detail: the parent, ornull.splitSiblings[], on the detail: every other order of the same customer order, the parent first, each withid,orderNumber,status,warehouseIdandwarehouseName. 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.
| Operation | Method & path | Scope |
|---|---|---|
| Create label | POST /api/shipping/labels | shipping:write · tenant-wide only |
| Available services | GET /api/shipping/services | shipping:read |
| List shipments | GET /api/shipping/shipments | shipping:read |
| Shipments summary | GET /api/shipping/shipments/summary | shipping:read |
| Delayed shipments, by attention bucket | GET /api/shipping/shipments/attention | shipping:read |
| Retrieve shipment (+ tracking) | GET /api/shipping/shipments/:id | shipping:read |
| Live carrier tracking | GET /api/shipping/shipments/:id/track | shipping:read |
| Update tracking on a generic label | PATCH /api/shipping/shipments/:id/tracking | shipping:write · tenant-wide only |
| Void label | POST /api/shipping/shipments/:id/void | shipping:write · tenant-wide only |
| Shipped items (per-SKU) | GET /api/shipping/shipped-items · /summary | shipping: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.
{
"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/:idreturnstracking: null, andGET /api/shipping/shipments/:id/trackreturnsstatus: "UNKNOWN", the stored shipment status asrawStatus, and no events — neither contacts a carrier.- Every shipment carries
canEditTracking(true for an active, outbound Generic Label parcel whose pack is completed) andcanMarkShipped(that, the order is waiting atFULFILLED, and this parcel still holds its say over the order's status — no carrier scan will ever ship it). PATCH /api/shipping/shipments/:id/trackingadds, corrects, or clears tracking on such a parcel. Send the newtrackingNumberandtrackingUrl(http/https, ornull) together with the values you last read asexpectedTrackingNumber/expectedTrackingUrl; if they no longer match, someone changed it first and the update returns409. Clear tracking with an emptytrackingNumberandtrackingUrl: 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 (409otherwise). A new, non-emptytrackingNumberis also refused with409on 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
trackingNumberqueues 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 atFULFILLED, saving a new number also advances it toSHIPPED— on any order, channel or not — which emits theorder.shippedwebhook 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.
| Operation | Method & path | Scope |
|---|---|---|
| List SKUs with stock | GET /api/inventory/skus | inventory:read |
| Retrieve a SKU | GET /api/inventory/skus/:id | inventory:read |
| Stock by location | GET /api/inventory/stock | inventory:read · tenant-wide only |
| Why a SKU's stock moved | GET /api/inventory/stock-changes | inventory: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.
| Operation | Method & path | Scope |
|---|---|---|
| List returns | GET /api/returns | returns:read |
| Retrieve a return | GET /api/returns/:id | returns:read |
| List a client's return reasons | GET /api/clients/:id/return-codes | returns:read |
| Create a return | POST /api/returns | returns: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.
| Operation | Method & path | Scope |
|---|---|---|
| Find clients by name (with default warehouse) | GET /api/clients/lookup | clients:read |
| List warehouses | GET /api/warehouse/warehouses | clients:read |
| Week-over-week account health | GET /api/clients/:id/health | orders:read |
| Agent operating policy | GET·PUT /api/clients/:id/agent-policy | policy: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.
| Operation | Method & path | Scope |
|---|---|---|
| List invoices | GET /api/billing/invoices | billing:read |
| Retrieve an invoice | GET /api/billing/invoices/:id | billing:read |
| Refresh an invoice's signed download URLs | GET /api/billing/invoices/:id/signed-urls | billing:read |
| Download an invoice (CSV or PDF) | GET /api/billing/invoices/:id/download?format=csv · pdf | billing:read |
| List billable charges | GET /api/billing/charges | billing:read |
| List charge adjustments | GET /api/billing/adjustments | billing:read |
| List billing periods | GET /api/billing/periods | billing:read |
| Retrieve a billing period | GET /api/billing/periods/:id | billing:read |
| List rate cards | GET /api/billing/rate-cards | rate-cards:read |
| Retrieve a rate card (with its rules) | GET /api/billing/rate-cards/:id | rate-cards:read |
| A rate card's version chain | GET /api/billing/rate-cards/:id/versions | rate-cards:read |
| A rule's pricing history | GET /api/billing/rate-cards/:id/rules/:ruleId/history | rate-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.