Tracking and line items
An order in this API is a header: totals, status, dates. The two things an integration usually needs next hang off it as sub-resources:
| Endpoint | Gives you |
|---|---|
GET /v1/orders/{orderNumber}/shipments |
shipments, tracking numbers, ship and delivery dates |
GET /v1/orders/{orderNumber}/items |
line items: SKU, quantity, per-line money and ship state |
Both need the same scope as the order itself, partner-api/orders:read. If you can already
read an order, you can already read its shipments and its lines - there is no second grant to
request.
Both are keyset-paginated the same way GET /v1/orders is, and their cursors work the same
way, with one rule: a cursor belongs to the endpoint that issued it. Passing a cursor from
/v1/orders to /shipments is rejected as 400 invalid_cursor rather than quietly returning
the wrong page.
Tracking
Section titled “Tracking”Read trackingNumbers, and nothing else
Section titled “Read trackingNumbers, and nothing else”curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.partners.collaterate.com/v1/orders/1000234/shipments" | jq{ "shipments": [ { "orderNumber": 1000234, "siteId": 7, "shipmentNumber": 1, "shippingService": "FedEx Ground", "trackingNumbers": ["794658123456", "794658123457"], "manualTrackingNumber": null, "shippedOn": "2026-06-03T09:10:00.000Z", "deliveredOn": "2026-06-05T16:41:00.000Z", "estimatedShipBy": "2026-06-03T00:00:00.000Z", "shipBy": "2026-06-04T00:00:00.000Z", "deliverBy": "2026-06-06T00:00:00.000Z", "packages": [ { "sequence": 1, "trackingNumber": "794658123456" }, { "sequence": 2, "trackingNumber": "794658123457" } ] } ], "nextCursor": null, "packagesComplete": true}trackingNumbers is an array of strings and is never null. It is the field to read. The
reason it exists as a separate field, rather than you assembling it yourself, is below.
The precedence rule, and why there is one
Section titled “The precedence rule, and why there is one”Collaterate records tracking numbers in two independent places, and they are complementary paths, not a deprecated pair. Both are in active use. Over the last twelve months, of 347,201 shipments:
| Where the number lives | Shipments | Share |
|---|---|---|
| Per-package, issued by the carrier at label generation | 213,387 | 61.5% |
| Shipment-level, typed in by a person | 19,870 | 5.7% |
| Both | 47 | 0.01% |
| Neither (yet) | 113,897 | 32.8% |
The per-package number comes from carrier-integrated label generation and there is one per package, so a shipment can legitimately carry several. The shipment-level number is a free-text field somebody fills in for a shipment that did not go through that integration.
This is stated as a rule rather than left to whatever the query returns because without one you
would see intermittent nulls: poll the same shipment three times and get a number, a different
number, then nothing, depending on row order. Both raw sources stay visible -
manualTrackingNumber on the shipment and packages[].trackingNumber - so you can tell where a
number came from if you need to. Do not reimplement the precedence from them.
The invariant that tells you when to stop polling
Section titled “The invariant that tells you when to stop polling”That gives you a clean polling rule:
trackingNumbersempty andshippedOn: null- not shipped yet. Keep polling.trackingNumbersnon-empty - you have what you came for. Stop polling this shipment (or keep going untildeliveredOnis set, if you track delivery too).trackingNumbersempty andshippedOnset - this should not happen. It is a data fault worth reporting to Partner Integrations with therequestId, not a state to code around.
Tracking is absent only before a shipment goes out, never after. A number you have already seen will not disappear.
Polling, given there is no change feed
Section titled “Polling, given there is no change feed”There is still no updatedSince and no change feed - see
Getting started for why
updatedOn cannot substitute for one. What you can do is poll a specific order, because
/shipments always reflects current state:
import requests
BASE_URL = "https://api.partners.collaterate.com/v1"
def tracking_for(token: str, order_number: int) -> list[str]: """Every tracking number currently known for one order.""" numbers: list[str] = [] cursor: str | None = None
while True: params: dict[str, str | int] = {"limit": 50} if cursor is not None: params["cursor"] = cursor
response = requests.get( f"{BASE_URL}/orders/{order_number}/shipments", headers={"Authorization": f"Bearer {token}"}, params=params, timeout=10, ) if response.status_code == 404: # The order number does not resolve for you: no such order, or not one of your # sites. NOT "no shipments yet" - that is a 200 with an empty array. raise LookupError(f"order {order_number} is not visible to this credential") response.raise_for_status() body = response.json()
if not body["packagesComplete"]: # Vanishingly rare, and never to be ignored: some shipment's package list was # truncated, so some tracking number is missing from this response. raise RuntimeError("incomplete package set; retry with a smaller limit")
for shipment in body["shipments"]: numbers.extend(shipment["trackingNumbers"])
cursor = body["nextCursor"] if cursor is None: break
return numbersThe supported pattern is: discover new orders by paginating GET /v1/orders, keep your own
list of the ones not yet delivered, and re-request /shipments for each of them on your own
schedule. What is missing is only the ability to ask which orders changed - not the ability to
see current state for one you already know about. Mind the
rate limits when you choose that schedule.
404 versus an empty list
Section titled “404 versus an empty list”An order that resolves for you and simply has no shipments yet returns 200 with
"shipments": []. A 404 means the order number does not resolve for you - no such order,
or an order in a site you were not granted, indistinguishable on purpose
(why). The two mean completely different things to your
code: the first is “wait”, the second is “your order number is wrong”.
There is no carrier field
Section titled “There is no carrier field”shippingService carries the carrier: it reads FedEx Ground, UPS Next Day Air®,
FedEx Priority Overnight and so on, and it is always present on a shipment that has actually
shipped. There is no separate carrier string.
That is a deliberate omission rather than an oversight. The carrier is stored as a reference to
a table that has no site of its own, so it cannot be read through this API’s tenancy boundary
without a join - and that join would have to be an inner one, which would silently drop the 33%
of shipments that have no carrier assigned yet. Those are precisely the shipments you are
polling. A field that made a third of your open shipments vanish would be a much worse trade
than a carrier name you can read off shippingService.
Similarly there is no package weight or dimensions. The units are not recorded anywhere in the platform’s schema, and a bare number whose unit you would have to guess is worse than no field at all. Ask Partner Integrations if you need them and we will publish them with the units settled.
packagesComplete
Section titled “packagesComplete”true in effectively every response. false means the response hit its package ceiling (5,000
packages) and at least one shipment’s packages - and therefore its trackingNumbers - is
incomplete. Re-request with a smaller limit.
This is reported rather than truncated silently for one reason: a truncated package list is a
missing tracking number, and a missing tracking number arriving inside a well-formed 200 is
undetectable from your side. Treat false as an error in your own code, as the example above
does. One shipment in the platform’s history has reached 1,983 packages, so the ceiling is not
theoretical - but you will not reach it with a sane limit.
Line items
Section titled “Line items”curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.partners.collaterate.com/v1/orders/1000234/items" | jq{ "items": [ { "orderNumber": 1000234, "siteId": 7, "itemNumber": 884213, "sku": "BC-16PT-MATTE-1000", "productName": "Business Cards", "productCode": "business-cards", "jobName": "Q3 sales team cards", "externalId": "acme-line-4471", "quantity": 1000, "lineListTotal": "49.0000000000", "lineTotal": "46.5500000000", "shipped": true, "shippedOn": "2026-06-03T09:10:00.000Z", "cancelled": false, "estimatedShipBy": "2026-06-03T00:00:00.000Z", "shipBy": "2026-06-04T00:00:00.000Z", "turnaroundBusinessDays": 3 } ], "nextCursor": null}A line is identified by (orderNumber, itemNumber). There is no line id.
sku is often null
Section titled “sku is often null”Roughly 55% of line items in the platform have no SKU, because a configured print job is not
necessarily a catalogue product. If you place orders by SKU you will read your own SKU back
here. If you read lines you did not create, handle null.
externalId is the better correlation key if you set it: whatever your system put there when
the line was created comes back unchanged.
Cancelled lines stay in the list
Section titled “Cancelled lines stay in the list”A cancelled line is returned with cancelled: true. It is not filtered out.
That is deliberate. A line that vanished between two polls looks like a bug in your own reconciliation and takes a long time to diagnose; a flag is an answer you can act on. So do not assume the list contains only live lines - decide explicitly what your code does with a cancelled one.
Lines you will not see
Section titled “Lines you will not see”Two exclusions are applied, and neither is a filter you can turn off:
- Lines hidden from the customer. If the platform hides a line from the customer who placed the order, it is hidden from you too. 633 of 664,132 lines over twelve months.
- Internal production records. Rework and proof records are children of a real line and never carry a SKU. Including them would double-count an order’s contents.
So an order can legitimately return an empty items array. That is a 200, not a 404; a
404 means the order number does not resolve for you at all, exactly as on /shipments.
If line totals do not sum to the order total
Section titled “If line totals do not sum to the order total”There is one rare data condition worth knowing about before you spend an afternoon on it. A line whose own site assignment disagrees with its order’s is shown to nobody, because neither site’s claim on it can be trusted and showing it to either would disclose one site’s data to the other. It affected 26 of 664,132 lines over twelve months.
If an order’s line totals do not sum to its totals.item, this is the first thing to ask
Partner Integrations about - with the order number and a requestId.
- Errors - every code these endpoints can return, and what to retry.
- Rate limits and quotas - relevant if you poll tracking.
- Tenancy and access - why
404and never403.