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.

The two endpoints do not share a page size, though. /shipments defaults to 25 and caps at 50 - deliberately lower than /v1/orders, because each shipment row can carry many packages. /items defaults to 50 and caps at 200, the same as /v1/orders. On both, a limit that is not a positive integer falls back to the default rather than erroring.

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

jobName and jobDescription are the two human labels. jobName is yours to set - it is a per-line field on the order submission body, and it comes back here unchanged. Use externalId to correlate against your own records and jobName for something a person will read.

variantChoices says which variant was ordered, as name/choice pairs - the same shape a product’s attributes uses, so a variant reads identically whichever end you got it from:

"variantChoices": [{ "name": "Size", "choice": "Large" }]

It is [] on the majority of lines, which have no choices at all. Without it a stock line names the product but not which version of it.

handlingTotal is charged separately upstream and is not included in lineTotal. An invoice reconciliation that ignores it will come up short.

Each line carries a discounts object with two independent sides, kept apart rather than summed:

"discounts": {
"product": { "amount": "101.3200000000", "percent": "50.0000000000" },
"user": { "amount": "1.0100000000", "percent": "1.0000000000", "name": "Discount1" }
}

product belongs to what was bought; user belongs to who bought it, and is the only one with a name. Either side is null when that discount did not apply - never a zero amount.

Between “ordered” and “shipped” sits the work. Two fields on every line describe it, and for a print job the proof step is usually the one your customer is asking you about.

proofStatus meaning
NOT_CREATED no proof exists yet
PENDING_REVIEW waiting on somebody, usually your customer
APPROVED signed off, production can proceed
DECLINED rejected; the line is stalled until a new proof is approved
productionStatus meaning
PLANNING scheduled, not yet released
PRODUCTION_READY released to the floor
QUEUED_FOR_PRINT queued
PRINTED printed, not yet finished
FINISHED production complete

PENDING_REVIEW and DECLINED are the two worth alerting on. A line can sit in PENDING_REVIEW indefinitely, and nothing else in this API would tell you why an order has stopped moving. You can now also act on one - see Approving a proof below.

Three more line fields landed alongside these:

  • shippable - whether the line will ever produce a shipment. Check it before polling /shipments for a line: an unshippable line never appears there, so polling for it is waiting for something that cannot happen. About three lines in a hundred.
  • digital - delivered digitally. Independent of shippable; read both.
  • isReorder - this line is a reorder of an earlier one, about one line in ten. Which line it reorders is not published: we record it as an internal row id that has no meaning across this API.
  • backorderQuantity - units on backorder, or null when none has been computed. null and 0 are different answers and are not flattened.

Seeing proofStatus: PENDING_REVIEW tells you an order has stopped moving. Four endpoints let you do something about it, all hanging off one line:

Endpoint Scope Gives you
GET .../items/{itemNumber}/proof orders:read the proof files and rendered preview
GET .../items/{itemNumber}/proof/denial-reasons orders:read the reasons this line may be declined with
POST .../items/{itemNumber}/proof/approve orders:write approve, releasing it into production
POST .../items/{itemNumber}/proof/decline orders:write decline with a reason

All four are under /v1/orders/{orderNumber}/items/{itemNumber}/. The reads take the same scope the rest of the order tree does; the two decisions take orders:write, the scope you already hold to submit orders. There is no separate proof grant to request.

itemNumber is the value GET /v1/orders/{orderNumber}/items returns as itemNumber. It is the only line identifier this API publishes - there is no internal proof id, file id or line id anywhere in these endpoints, deliberately, because none of them means anything on your side of the boundary.

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.partners.collaterate.com/v1/orders/1000234/items/884213/proof" | jq
{
"orderNumber": 1000234,
"itemNumber": 884213,
"proofStatus": "PENDING_REVIEW",
"files": [
{
"name": "business-cards-proof-r1.pdf",
"link": null,
"active": false,
"createdOn": "2026-05-24T09:10:00.000Z"
},
{
"name": "business-cards-proof-r2.pdf",
"link": "https://files.example.com/proofs/8f3c...?sig=...",
"active": true,
"createdOn": "2026-06-01T14:22:00.000Z"
}
],
"visualUrl": "https://visuals.example.com/884213/page-1.png?sig=..."
}

files lists every revision oldest first, with active marking the current one - the one a decision applies to. Older revisions are returned rather than filtered out, for the same reason cancelled lines are: a shorter list with no explanation is a mystery, a flag is an answer.

That is the older revision in the response above, and it is not an error. The file is still listed - name, active, createdOn - because it really is part of the line’s history. What is missing is only the download URL, and there are three reasons for it:

  • the file has been moved to secure long-term storage. Getting it back is an operator action and can take hours;
  • the file is queued for retrieval from archive storage, or is still being copied into the tier downloads are served from;
  • the URL could not be minted for that one file on this request.

Show the file and treat the link as pending, then call the endpoint again later - the same call, unchanged. Nothing you can put in the request changes the answer, so if a link you need is still missing hours later, send the orderNumber, itemNumber and the file’s name to your Collaterate contact.

One file’s missing link never costs you the others: the response is still a 200 and every other file keeps its link. A read that genuinely failed is a 503, never a 200 with an empty files array - an empty array means this line has no proof files yet.

Terminal window
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"comment":"Looks good, please run it."}' \
"https://api.partners.collaterate.com/v1/orders/1000234/items/884213/proof/approve"

The body is optional - send none at all to approve without a comment. The response is a small acknowledgement:

{ "orderNumber": 1000234, "itemNumber": 884213, "proofStatus": "APPROVED" }

Read the reasons for that line first. They are not a global list: they are configured on the proof product the line was ordered with, so two lines on the same order can offer different sets.

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.partners.collaterate.com/v1/orders/1000234/items/884213/proof/denial-reasons" | jq
{
"reasons": [
{ "code": "NEW_FILES_TO_FOLLOW", "name": "I will provide new files for print.", "defaultSelected": false },
{ "code": "COLOR_INCORRECT", "name": "The layout is good, but the color is off.", "defaultSelected": false }
]
}

Branch on code; show name. name is Collaterate’s own display text and a supplier can reword it at any time without notice, which is exactly why code exists.

Then send one of those codes as reason:

Terminal window
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"reason":"COLOR_INCORRECT","comment":"The blue in the logo is reading purple."}' \
"https://api.partners.collaterate.com/v1/orders/1000234/items/884213/proof/decline"

comment is optional but often the useful part - it is what prepress reads to know what to fix. One reason (SEE_COMMENT) exists specifically to be paired with one.

Decisions are only accepted from PENDING_REVIEW

Section titled “Decisions are only accepted from PENDING_REVIEW”

Both approve and decline refuse anything else with 409:

Situation code
no proof yet, already approved, already declined, or a state we do not recognise proof_not_pending_review
the line has been cancelled order_item_cancelled

detail says which. Neither refusal reaches Collaterate, so neither can partially apply.

Where a shipment is going, and what is in the box

Section titled “Where a shipment is going, and what is in the box”

Every shipment carries shipTo - the destination address - in the same shape and the same vocabulary you send it on POST /v1/orders. state is an abbreviation, country a two-letter code, so an address reads the same coming back as it did going out.

{
"shipmentNumber": 1,
"shipTo": {
"company": "Acme Ltd", "firstName": "Dana", "lastName": "Reicher",
"address1": "1 Example Way", "address2": null,
"city": "Minneapolis", "state": "MN", "country": "US",
"postalCode": "55401", "phone": "+1 555 0100", "residential": false
},
"fulfilment": { "shipmentCreated": true, "ready": true, "picked": false },
"packages": [
{ "sequence": 1, "trackingNumber": "794658123456",
"weight": "2.5000000000", "length": null, "width": null, "height": null }
]
}

fulfilment tells you where a shipment is before shippedOn is set - previously you could only see shipped or not shipped. The three flags are independent: do not infer an ordering between them, and do not assume picked implies ready. ready is the only one that can be null, which means not recorded rather than false.

Package weight is present on effectively every package; the three dimensions on roughly three in five, so expect null there. All four are decimal strings, and the units are pounds and inches by platform convention - no unit is stored alongside the value, so treat that as a convention rather than a guarantee.

shippingTotal is what the shipping cost the buyer, after adjustments. What the carrier charged Collaterate is not published.

blindShip tells you whether a shipment went out without Collaterate branding. There is no shipFrom - on a blind shipment the whole point is that the origin is not disclosed, so it is not published on any shipment.

The seven money fields under totals add up, and you can build on that:

item + shipping + tax - discount + handling - creditAccount = order

Watch the signs. discount and creditAccount are published as positive amounts and are subtracted; handling is added. order is what is payable.

{
"totals": {
"item": "1024.5000000000",
"shipping": "42.0000000000",
"tax": "81.9600000000",
"discount": "102.4500000000",
"handling": "12.5000000000",
"creditAccount": "50.0000000000",
"order": "1008.5100000000"
}
}

If a recent order does not reconcile, check order first: a small number have had their total zeroed after the fact - cancelled or written off - while their components were left in place, so the left-hand side comes out negative against an order of "0.0000000000". That shape is a cancelled order, not a missing field. Anything else recent is worth raising with Partner Integrations, with the order number and a requestId.

Every order carries externalId: the identifier the system that created it set on it. For an order you submitted through POST /v1/orders, that is the partnerOrderId you sent - so you can match our orders to yours without keeping the submission resource around.

totals.discount is on every order, in the list and the detail alike. It is a positive amount that has already been subtracted from totals.order. Do not add it to anything - order is what is payable.

GET /v1/orders/{orderNumber} additionally carries a discounts array naming the promotions behind it:

{
"totals": { "item": "1024.5000000000", "discount": "102.4500000000", "order": "1045.0500000000" },
"discounts": [
{ "code": "SPRING25", "name": "Spring 25% off", "amount": "102.4500000000" }
]
}

A code of null is also normal - about one promotion in eleven is automatic and needs no code typed. name is always present, and is what makes those rows readable.

The array is on the detail route only. The list would need one extra query per order, and a hundred-row page is not the place for it.

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.