Skip to content

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

StatusMeaning
sentThe store has sent you the order. Nothing has happened on your side yet.
acknowledgedYou have confirmed receipt.
partially_fulfilledAt least one parcel has shipped, but some ordered units have not.
fulfilledEvery line is shipped in full. fulfilledAt is set.
cancelledThe store cancelled the order.
declinedYou 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.

QueryBehaviour
page, limitlimit is 1 to 500, default 50.
sortBy, sortDirsortBy is one of createdAt, number, subtotal, status; anything else falls back to createdAt. sortDir is ASC or DESC, default DESC.
statusOne status.
statusInSeveral, comma separated: statusIn=sent,acknowledged. Takes precedence over status.
storeIdOnly orders from one store.
createdFrom, createdToISO 8601 bounds on createdAt. A bare date in createdTo covers the whole of that day.
updatedSinceOrders whose updatedAt is strictly after this instant.
customerRefOrders from the store whose customer reference or account reference equals this value, case-insensitively. See Mapping a store to your customer account.
keywordMatches 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:

HTTP
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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Two 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:

JSON
{
  "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.
  • shipTo never includes the customer's email. The address is what you need to ship; the customer relationship stays with the store.
  • vendorOrderNumber is the store's own order number, the one the customer sees. It is also accepted as an order reference by the tracking endpoint below.
  • externalRef is 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.customerRef and store.accountRef identify the account. See the next section.
  • Line-level tracking fields mirror the latest parcel. items[].trackingNumber and friends are a convenience copy of the newest entry in items[].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:

FieldWho sets itMeaning
store.customerRefYou, 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.accountRefThe store, on its connection to youThe 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

HTTP
POST /api/supplier-api/v1/orders/3d5f7e9c-1b0a-4c8e-9c4e-2b1a7f3d4c8e/acknowledge HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer sup_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

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

HTTP
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"
    }
  ]
}
JSON
{
  "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:

  • quantity is the units in this parcel, not the running total. The server adds it to the line's fulfilledQuantity. Ship the remaining 4 units later with a second call and quantity: 4.
  • One entry per line, 1 to 100 lines per call. Repeating an itemId in one request is a 400. An itemId that is not on this order is a 404.
  • Tracking is per line entry. trackingNumber (up to 100 characters), trackingCarrier (up to 50) and trackingUrl (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 quantity that would take the line past its ordered amount is a 400 that 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 skipped and 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_fulfilled or fulfilled, and updatedAt is bumped either way so an updatedSince poll 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:

JSON
{ "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.

HTTP
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" }
  ]
}
JSON
{
  "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:

  1. orderRef is tried three ways, in order: as your purchase order number (PO-2026-0418), then as the store's order number (#10492), then as your own externalRef. Pass externalRef separately if you want the third match to use a different value than orderRef. If a store order number matches purchase orders in two different stores, the row fails and asks you to use the PO number.
  2. lineSku picks 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:

  • quantityShipped is 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 trackingNumber but no trackingCarrier fails. The carrier is matched against the catalogue and the tracking URL is derived from it unless you supply trackingUrl.
  • Unchanged rows are skipped. Same quantity and same (or no) tracking number means nothing to do, and no error.
  • dryRun: true validates everything and writes nothing. Rows that would succeed come back as would-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 200 even when every row failed, so read meta.failed and the per-row error strings, and never treat a 200 alone as success.

Which endpoint to use

POST /orders/{id}/shipmentsPOST /tracking-updates
Keyed byOrbit ids (order id, line item id)Business references (PO number, store order number, your ERP ref, SKU)
QuantityUnits in this parcel (delta)Total shipped so far (cumulative)
Scope of a callOne order, up to 100 lines, atomicUp to 500 rows across any orders, each independent
Retry safetyIdempotency-KeyNaturally idempotent: an unchanged row is skipped
Dry runNoYes

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