Purchase orders and shipments
A purchase order is what an Orbit store sends you when a customer's order contains your products. This guide follows one through its life from your side: finding it, acknowledging it, shipping it parcel by parcel, and keeping the tracking current. It assumes you have a key from Getting started.
The lifecycle
| Status | Meaning |
|---|---|
sent | The store has sent you the order. Nothing has happened on your side yet. |
acknowledged | You have confirmed receipt. |
partially_fulfilled | At least one parcel has shipped, but some ordered units have not. |
fulfilled | Every line is shipped in full. fulfilledAt is set. |
cancelled | The store cancelled the order. |
declined | You declined the order. declineReason carries the reason. |
The API only ever shows orders the store has actually sent. A store's unsent drafts do not exist from your side: they are absent from lists and answer 404 by id, the same as an id that was never yours.
Shipping does not require acknowledging first. An order can go from sent straight to partially_fulfilled or fulfilled.
Listing and polling
GET /api/supplier-api/v1/orders returns a page of orders with the filters you would expect and one that matters more than the rest: updatedSince.
| Query | Behaviour |
|---|---|
page, limit | limit is 1 to 500, default 50. |
sortBy, sortDir | sortBy is one of createdAt, number, subtotal, status; anything else falls back to createdAt. sortDir is ASC or DESC, default DESC. |
status | One status. |
statusIn | Several, comma separated: statusIn=sent,acknowledged. Takes precedence over status. |
storeId | Only orders from one store. |
createdFrom, createdTo | ISO 8601 bounds on createdAt. A bare date in createdTo covers the whole of that day. |
updatedSince | Orders whose updatedAt is strictly after this instant. |
customerRef | Orders from the store whose customer reference or account reference equals this value, case-insensitively. See Mapping a store to your customer account. |
keyword | Matches the order number, the sending store's name, or either of its references. |
meta.statusCounts in every list response is a supplier-wide count per status. It ignores your filters, so it is the right number for a "3 new orders" badge regardless of which page you asked for.
The incremental sync loop
Polling everything on every run does not scale. Store the updatedAt of the newest order you processed and ask for what changed since:
GET /api/supplier-api/v1/orders?updatedSince=2026-09-06T17:40:12.000Z&sortBy=createdAt&sortDir=ASC&limit=100 HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer sup_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxTwo properties make this reliable:
- The comparison is strictly after, so the order you stored as your high-water mark is not returned again.
- Every write bumps
updatedAt, including a parcel you record yourself and a cancellation by the store, so the loop sees every change and not just status transitions.
Advance the mark only after a run completes. If a run fails part way, replaying the same window is harmless. Pair this with a webhook for supplier.purchase-order.sent if you want new orders within seconds rather than at the next poll.
What an order looks like
GET /api/supplier-api/v1/orders/{id} returns { "purchaseOrder": ... }. The fields you will use:
{
"id": "3d5f7e9c-1b0a-4c8e-9c4e-2b1a7f3d4c8e",
"number": "PO-2026-0418",
"status": "sent",
"externalRef": null,
"storeId": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"store": {
"id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"name": "Beacon Hardware",
"customerRef": "CUST-004821",
"accountRef": "TA-100482"
},
"vendorOrderId": "b1a2d5f7-e9c1-4b0a-8c4e-9c1b0a3d5f7e",
"vendorOrderNumber": "#10492",
"shipTo": {
"name": "Jane Armitage", "company": null,
"address1": "14 Abington Street", "address2": null,
"city": "Northampton", "state": null, "postalCode": "NN1 2AJ",
"country": "United Kingdom", "countryCode": "GB", "phone": null
},
"subtotal": "184.00",
"currency": "GBP",
"notes": null,
"sentAt": "2026-09-06T17:40:12.000Z",
"acknowledgedAt": null, "fulfilledAt": null, "cancelledAt": null, "declinedAt": null,
"declineReason": null,
"createdAt": "2026-09-06T17:40:11.000Z",
"updatedAt": "2026-09-06T17:40:12.000Z",
"items": [
{
"id": "9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a",
"supplierProductId": "6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e",
"name": "Galvanised bolt M10 x 60 (box of 50)",
"sku": "BOLT-M10-60",
"quantity": 12,
"unitCost": "15.00",
"lineTotal": "180.00",
"fulfilledQuantity": 0,
"trackingNumber": null, "trackingCarrier": null, "trackingUrl": null, "shippedAt": null,
"trackingStatus": null, "lastTrackingUpdate": null,
"shipments": []
}
]
}Things worth knowing:
- Money fields are strings (
subtotal,unitCost,lineTotal), decimal with two places. Do not parse them as floats for accounting. shipTonever includes the customer's email. The address is what you need to ship; the customer relationship stays with the store.vendorOrderNumberis the store's own order number, the one the customer sees. It is also accepted as an order reference by the tracking endpoint below.externalRefis yours. Your own reference for this purchase order, set on the order in the supplier dashboard; the tracking endpoint can then match rows on it.store.customerRefandstore.accountRefidentify the account. See the next section.- Line-level tracking fields mirror the latest parcel.
items[].trackingNumberand friends are a convenience copy of the newest entry initems[].shipments. The parcels are the record.
Mapping a store to your customer account
Your ERP does not know Orbit store ids. Two references on every order let it find the right account without a lookup table:
| Field | Who sets it | Meaning |
|---|---|---|
store.customerRef | You, on the store's customer record in the supplier dashboard (Customers, then the store, then Customer reference) | Your account code for that store. Unique across your customers. |
store.accountRef | The store, on its connection to you | The store's own trade account reference with you, as the buyer knows it. |
Either can be empty. Set customerRef once per store and every purchase order from that store carries it from then on; filter with ?customerRef=CUST-004821 to pull one account's orders. Both references also appear as Customer Ref and Account Ref columns in order exports and on the purchase order PDF.
Your reference wins. Once you set customerRef for a store, the store's own field locks on their side and shows your value, so a buyer cannot change the code you use for them. Until you set it, accountRef is the best hint you have about which account the store means.
Acknowledging
POST /api/supplier-api/v1/orders/3d5f7e9c-1b0a-4c8e-9c4e-2b1a7f3d4c8e/acknowledge HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer sup_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxNo body. Only a sent order can be acknowledged; any other status answers 400 with the current status in the message. The response is { "purchaseOrder": ... } with status: "acknowledged" and acknowledgedAt set. Acknowledging is optional but worth doing: it tells the store you have the order before anything ships.
Shipping a parcel
POST /api/supplier-api/v1/orders/{id}/shipments records one parcel. List the lines the parcel carries, how many units of each, and the tracking for the parcel.
POST /api/supplier-api/v1/orders/3d5f7e9c-1b0a-4c8e-9c4e-2b1a7f3d4c8e/shipments HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer sup_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Idempotency-Key: wms-despatch-88213
Content-Type: application/json
{
"lines": [
{
"itemId": "9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a",
"quantity": 8,
"trackingNumber": "JD0002234567GB",
"trackingCarrier": "dpd"
}
]
}{
"purchaseOrder": { "status": "partially_fulfilled", "items": [ { "fulfilledQuantity": 8, "shipments": [ { "quantity": 8, "trackingNumber": "JD0002234567GB", "trackingCarrier": "dpd", "trackingUrl": "https://track.dpd.co.uk/parcels/JD0002234567GB", "shippedAt": "2026-09-07T08:12:40.000Z" } ] } ] },
"lines": [ { "itemId": "9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a", "outcome": "shipped" } ]
}The rules:
quantityis the units in this parcel, not the running total. The server adds it to the line'sfulfilledQuantity. Ship the remaining 4 units later with a second call andquantity: 4.- One entry per line, 1 to 100 lines per call. Repeating an
itemIdin one request is a400. AnitemIdthat is not on this order is a404. - Tracking is per line entry.
trackingNumber(up to 100 characters),trackingCarrier(up to 50) andtrackingUrl(up to 1000) are all optional. Give the carrier as its code or name from the carrier catalogue; when you supply a number and a known carrier but no URL, the tracking URL is filled in from the carrier's pattern. - Over-shipping is refused. A
quantitythat would take the line past its ordered amount is a400that names the ordered and already-shipped counts. Nothing is written. - The whole call is atomic. The lines are applied in one transaction under a lock on the order, so two warehouse stations shipping the same order at once cannot double-count. Any error means nothing changed.
- A completed line is skipped, not an error. If a line is already fully shipped and you send it again with no tracking number or the same one, its outcome is
skippedand the rest of the call proceeds. That makes a replayed despatch file safe. The same line with a different tracking number is treated as a real over-ship and refused. - The order's status follows the lines. After the write it is
partially_fulfilledorfulfilled, andupdatedAtis bumped either way so anupdatedSincepoll on another system sees every parcel. - The store's order is fulfilled for you. Each parcel is materialised as a fulfilment on the store's customer order in the background, so the store and its customer see the tracking without the store doing anything.
Send an Idempotency-Key on this call. It is the one endpoint that supports it, and it is what makes a retried request after a timeout safe. Details in Idempotency, rate limits and errors.
The carrier catalogue
GET /api/supplier-api/v1/carriers returns the active carriers, ordered by name:
{ "items": [ { "name": "DPD UK", "code": "dpd", "trackingUrlPattern": "https://track.dpd.co.uk/parcels/{tracking_number}" } ] }Use code in trackingCarrier. A name is accepted too, matched case-insensitively. The catalogue is cached for five minutes on the server, so a newly added carrier can take that long to appear.
Pushing tracking by reference
Warehouse and courier systems often produce a despatch file keyed by order number and SKU, with no Orbit ids in it. POST /api/supplier-api/v1/tracking-updates takes that file directly, up to 500 rows at a time, and tells you what happened to each row.
POST /api/supplier-api/v1/tracking-updates HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer sup_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"dryRun": false,
"rows": [
{ "orderRef": "PO-2026-0418", "lineSku": "BOLT-M10-60", "trackingNumber": "JD0002234567GB", "trackingCarrier": "dpd", "quantityShipped": 12 },
{ "orderRef": "#10493", "trackingNumber": "JD0002234568GB", "trackingCarrier": "dpd" }
]
}{
"rows": [
{ "rowIndex": 0, "outcome": "applied" },
{ "rowIndex": 1, "error": "Row did not match any strategy: po_number: no PO numbered \"#10493\"; vendor_order_number: multiple POs match vendor order \"#10493\", disambiguate via PO number instead; external_ref: no PO with external ref \"#10493\"" }
],
"meta": { "total": 2, "applied": 1, "skipped": 0, "wouldApply": 0, "failed": 1 }
}How a row finds its line:
orderRefis tried three ways, in order: as your purchase order number (PO-2026-0418), then as the store's order number (#10492), then as your ownexternalRef. PassexternalRefseparately if you want the third match to use a different value thanorderRef. If a store order number matches purchase orders in two different stores, the row fails and asks you to use the PO number.lineSkupicks the line, matched case-insensitively on the item SKU. It can be omitted on a single-line order. On a multi-line order without it, or with a SKU that appears twice, the row fails with a message saying so.
How a row is applied:
quantityShippedis cumulative: the total shipped for that line after this row, not a delta. Omit it to mark the whole line shipped. It cannot go down, and it cannot exceed the ordered quantity.- A tracking number needs a carrier. A row with
trackingNumberbut notrackingCarrierfails. The carrier is matched against the catalogue and the tracking URL is derived from it unless you supplytrackingUrl. - Unchanged rows are
skipped. Same quantity and same (or no) tracking number means nothing to do, and no error. dryRun: truevalidates everything and writes nothing. Rows that would succeed come back aswould-apply. Run a new despatch export through a dry run first.- Rows are independent. They are processed in order, a failure is recorded in place, and the batch never aborts. The HTTP status is
200even when every row failed, so readmeta.failedand the per-rowerrorstrings, and never treat a200alone as success.
Which endpoint to use
POST /orders/{id}/shipments | POST /tracking-updates | |
|---|---|---|
| Keyed by | Orbit ids (order id, line item id) | Business references (PO number, store order number, your ERP ref, SKU) |
| Quantity | Units in this parcel (delta) | Total shipped so far (cumulative) |
| Scope of a call | One order, up to 100 lines, atomic | Up to 500 rows across any orders, each independent |
| Retry safety | Idempotency-Key | Naturally idempotent: an unchanged row is skipped |
| Dry run | No | Yes |
Integrations that hold Orbit ids, typically because they were triggered by a webhook or an updatedSince poll, use the shipments endpoint. Integrations that start from a despatch file use tracking updates.
Next steps
- Hear about new orders the moment they are sent: Webhooks.
- Retry writes safely and stay inside the limits: Idempotency, rate limits and errors.
- Field-by-field schemas: Supplier API Reference.