Skip to content

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.

Terminal window
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.

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:

  • trackingNumbers empty and shippedOn: null - not shipped yet. Keep polling.
  • trackingNumbers non-empty - you have what you came for. Stop polling this shipment (or keep going until deliveredOn is set, if you track delivery too).
  • trackingNumbers empty and shippedOn set - this should not happen. It is a data fault worth reporting to Partner Integrations with the requestId, 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.

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:

poll_tracking.py
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 numbers

The 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.

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”.

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.

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.

Terminal window
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.

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.

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.

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.