Conventions
Idempotency, error schema, rate limits, pagination, filtering, and incremental sync — the rules that apply across every endpoint.
These rules apply across the whole API.
Idempotency
Every write endpoint (POST, PATCH, PUT and DELETE — creates, edits,
holds, cancels, line changes, voids) accepts an Idempotency-Key header.
Send a unique key (e.g. a UUID) per logical operation and you can safely retry
on timeouts or network errors without the write landing twice. A request
without the header behaves as it always has.
curl -X POST https://api.staging.canopywms.com/api/orders \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: 6f1a8c2e-..." \
-H "Content-Type: application/json" \
-d '{ "...": "..." }'Behavior:
| Situation | Result |
|---|---|
| First request with a key | Runs normally; the 2xx response is stored against the key. |
| Retry after success | Returns the original status and body verbatim, with an Idempotent-Replay: true header. |
| Retry more than 24 hours after the first request | The key has expired: the request runs again as a new operation (and reserves the key afresh). Retry within 24 hours if you need the replay. |
| Concurrent duplicate (first still in flight) | 409 — A request with this Idempotency-Key is already in progress. If the first request has been pending for more than 5 minutes it is presumed dead, and the duplicate takes over the key and runs instead. |
| Same key reused for a different operation (other method, URL, or a changed body), or by a different credential or a token with different scopes | 422 — Idempotency-Key was already used for a different operation. A replay only ever goes to the caller that made the original request. |
Only successful (2xx) responses are cached, so a transient validation error never "poisons" a key.
A key is at most 255 characters. It lives in the partition of the client the
write targets: a client-pinned credential's own client, or, for a tenant-wide
credential, the clientId in the body (plus the orderId on a label buy). So
a tenant-wide credential that reuses one key for two different clients has
sent two different keys, and both requests run. Within a partition the key is
bound to the operation (method, path, body) and to the caller (the credential,
its role and its token scopes), which is what the 422 row above enforces.
Domain-level idempotency for purchase orders
Purchase-order creates are also idempotent on your external PO number —
re-creating a PO with an externalPoNumber that already exists returns the
existing PO rather than a duplicate, independent of the Idempotency-Key
header. See Endpoints → Purchase orders.
Response envelope
Every success response is { "success": true, "data": ... }. List endpoints add a
pagination block beside data; a few reads (order notes, blocked orders,
client lookup, shipping services, and the billing lists of charges,
adjustments, periods and rate cards) return a bare array in data with no
pagination. GET /api/billing/invoices adds the block only when you send
page (see Pagination). GET /api/billing/invoices/:id/download
is the one success that is not JSON: it streams the file.
Documented fields are a floor, not a ceiling: new fields may appear
at any time, so ignore keys you don't recognise. The full shape of every
response is in the Reference section.
Errors
Errors use a consistent JSON shape:
{
"success": false,
"error": "Human-readable message describing what went wrong"
}A 400 from request validation adds a details map — field name to the
messages for that field:
{
"success": false,
"error": "Validation error",
"details": { "updatedSince": ["Invalid datetime"] }
}(The token endpoint is the one exception — see Authentication → Token errors.)
Conditions are distinguished by HTTP status:
| Status | Meaning |
|---|---|
400 | Validation error (malformed or missing fields — with a details map), or the operation is not allowed in the resource's current state: a status that forbids it, a cancel of a split order, a PO cancel after units were received or rejected, a line removed while the order is on an in-flight pick list, a label bought with a service the carrier account does not offer |
401 | Missing or invalid token |
403 | Token lacks the required scope, the endpoint isn't credential-callable, the operation is warehouse staff's call (a line reduced on an order that is being picked; a reship or carrier override, or any rate-card read, from a client-pinned credential), a client-pinned credential releasing a hold its own client did not place, the request came from an IP outside the credential's allowlist, or the body sets a warehouse-staff-only field (updateLines[].binId on a purchase order) |
404 | Resource not found within your tenant/client scope — including an orderId passed to a shipping rate quote that doesn't resolve within it |
409 | Conflict — an idempotency duplicate still in flight, a write that raced a concurrent change (re-read and retry), a write that collides with existing state (e.g. an active webhook endpoint already uses that URL, or an order created with an orderNumber its client already uses — existingOrderId names it), a label for an order that shipped inside another order's merged box (Order CC-… shipped in merged order CC-…'s box — reship or relabel CC-… instead), a line reduced or removed while a reship is re-sending it (This line is being re-sent for a reship, and a re-send ships whole — let it go out…), a cancel refused because another warehouse's open re-send is taking units off this order's box (<order numbers>'s re-send is taking units off this order's box <tracking> — cancel that re-send on its order first, or let it go out), a void refused because an open re-send is replacing that box (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.), or a cancel while a parcel already has carrier scans, a wholesale order has a live freight booking, or a wholesale shipment cycle is already complete; the body's error says which |
422 | Idempotency key reused for a different operation |
429 | Rate limit exceeded |
5xx | Transient server error — safe to retry with the same Idempotency-Key |
A void refused because an open re-send is replacing the box also carries a machine-readable code field alongside error, so a credential or agent can branch on the reason without matching the message text:
{
"success": false,
"error": "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.",
"code": "OPEN_RESHIP"
}Rate limits
Each credential has its own requests-per-minute limit. Responses carry the standard rate-limit headers so you can pace yourself:
x-ratelimit-limit: 600
x-ratelimit-remaining: 591
x-ratelimit-reset: 42When you exceed the limit you get 429 with a retry-after header. The token
endpoint (/oauth/token) is separately limited per source IP. Need a higher
limit? Ask your CanopyWMS administrator — the per-credential limit is
configurable.
Pagination
List endpoints are paginated with page and limit (max 500) query
parameters, and return a pagination block:
{
"success": true,
"data": [ /* ...rows... */ ],
"pagination": { "page": 1, "limit": 50, "total": 1284, "totalPages": 26 }
}Most lists also accept sortBy, sortOrder (asc/desc), and search.
The billing lists are the exception. GET /api/billing/invoices paginates
only when you send page: limit then defaults to 25 (max 250) and the
pagination block appears; without page, limit is ignored and every
invoice in scope comes back. Charges, adjustments, periods and rate cards
return the whole list in data with no page, limit or pagination.
Filtering & incremental sync
List endpoints expose resource-specific filters (status, date range, and exact
external-reference lookups — see each resource under
Endpoints). Date-range filters such as dateFrom /
dateTo are calendar days; the shipment list and summary also take
shippedFrom / shippedBefore as exact timestamps (see
Endpoints → Shipments).
For incremental sync / changed-since polling, orders, purchase orders, and
shipments accept an updatedSince parameter. It must be a full RFC 3339
datetime with a Z or a numeric offset (2026-06-29T00:00:00Z,
2026-06-28T19:00:00-05:00); a date-only value such as 2026-06-29 is a
400.
Orders and shipments also sort by updatedAt, so combine updatedSince
with sortBy=updatedAt&sortOrder=asc to walk every change since your last poll:
curl "https://api.staging.canopywms.com/api/orders?updatedSince=2026-06-29T00:00:00Z&sortBy=updatedAt&sortOrder=asc&limit=200" \
-H "Authorization: Bearer $ACCESS_TOKEN"Purchase orders accept updatedSince but not sortBy=updatedAt: the
PO list only sorts by its own column names (created, expected, vendor,
orderStatus, order, customer, tracking, note, inventoryStatus,
receivedRatio), and any other sortBy — including updatedAt — is silently
ignored, leaving the list ordered by createdAt descending. A delta walk on
POs must therefore page the whole updatedSince window (every page, not
"until the last row's timestamp") or poll with a generous overlap, rather than
rely on updatedAt ordering.
Versioning
All endpoints live under the /api path. Event-type names and response fields
are treated as a stable, additive contract — new fields and new webhook
event types are added without breaking existing consumers, so build your
integration to ignore unknown fields.
Authentication
OAuth2 client-credentials authentication — minting tokens, scopes, IP allowlisting, secret rotation, and environment isolation.
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.