# 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](/supplier-api/guides/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](#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:

```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](/supplier-api/guides/webhooks) 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:

| 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

```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](#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](/supplier-api/guides/reliability#idempotency-key).

## 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}/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](/supplier-api/guides/webhooks).
- Retry writes safely and stay inside the limits: [Idempotency, rate limits and errors](/supplier-api/guides/reliability).
- Field-by-field schemas: [Supplier API Reference](/supplier-api/reference).
