# Orders

> Create customer orders, record fulfillment and corrections, and add optional delivery tracking.

Buyers place orders in their trade portal. Suppliers handle invoicing. They can optionally offer [Stripe-hosted customer payments](/docs/guides/customer-payments) after confirming the final total. Order requests and trade credit remain available. Use the API to pull orders into your ERP or accounts system and to keep their status up to date.

## Order statuses

| Status         | Meaning                                |
| -------------- | -------------------------------------- |
| `new`          | Placed                                 |
| `pending`      | On hold                                |
| `acknowledged` | Seen and being picked                  |
| `processed`    | Ready for dispatch                     |
| `dispatched`   | Sent by the supplier                   |
| `completed`    | Supplier marked complete               |
| `cancelled`    | Canceled with a customer-facing reason |

Staff may skip intermediate picking stages. Moving backwards or reopening a completed or canceled order requires an internal `correctionReason`. Previous history stays. Corrections never alter payment state. Read `revision` and send `expectedRevision` with updates to reject stale edits.

## Create an order for a customer

**POST** `/api/v1/orders/preview`

Requires `orders.create` and access to the customer. Send `customerAccountId` and `lines` containing `productId` and integer `qty` (1 to 1,000,000). Choose each ordinary product once, at most 100 lines. A line may include optional `configuration` containing the returned measurement and choice keys; different configurations of one product remain separate. Optional `lineKey` gives each preview line a stable caller identity. An additional `additionalDiscountBp` needs pricing permission and `discountReason`. Configured order reads include an immutable `configuration` snapshot with inputs, specification, version, rounding and charges. The preview returns `total`, `fingerprint`, current customer prices and currency. Paused accounts, unavailable products and quantities below the minimum are refused. A prices-only catalog still accepts an order entered here; buyers cannot send one themselves while that is off.

**POST** `/api/v1/orders`

Send the same customer and lines, `expectedTotal` from the preview, `expectedBasket` set to its fingerprint, and a fresh UUID `requestKey`. Optional `poNumber` (60 characters) and customer-visible `notes` (2,000 characters) are supported. Reuse exactly the same request and key after an uncertain response: a successful retry returns the original order rather than creating another. Changed prices require another review. The result is `placed` with `orderId` and `reference`, or `prices_changed`, `empty` or `unavailable`.

Staff act under their own identity. The customer's cart is untouched. Customer email contacts may receive a copy; this does not charge payment. Sales Rep has creation access by default; processing and delivery updates require the separate `orders.process` permission.

## Delivery and tracking

**PATCH** `/api/v1/orders/{id}/delivery`

Requires `orders.process`. Send `deliveryMethod`: `unspecified`, `courier`, `own_delivery` or `collection`. Optional `courierName` (100 characters), `trackingReference` (120 characters) and `trackingUrl` (1,000 characters) are customer-visible. Tracking links must use HTTPS without embedded login credentials. Omitted optional details clear them. Send `expectedRevision` from the current order. There is one delivery record per order; carrier integrations and split shipments are not included. Blank courier details never imply own delivery.

## List orders

**GET** `/api/v1/orders`

Orders newest first. The list covers the **200 most recent** orders that match your filters. To pick up new orders, poll with `status=new`.

| Name       | Type    | Required | Description                  |
| ---------- | ------- | -------- | ---------------------------- |
| `status`   | string  | no       | One of the statuses above.   |
| `customer` | string  | no       | Customer id or account code. |
| `limit`    | integer | no       | 1 to 100. Default 100.       |
| `offset`   | integer | no       | Rows to skip. Default 0.     |

```bash
curl "https://tradecatalog.app/api/v1/orders?status=new" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```js
const res = await fetch('https://tradecatalog.app/api/v1/orders?status=new', {
  headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})
const { data: orders } = await res.json()
```

```json
{
  "data": [
    {
      "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
      "reference": "SO-000142",
      "status": "new",
      "total": 31460,
      "currency": "GBP",
      "poNumber": "PO-88231",
      "createdAt": "2026-09-27T07:48:12.000Z",
      "customer": "Oakfield Joinery"
    }
  ],
  "nextOffset": null
}
```

| Field       | Type           | Description                                                           |
| ----------- | -------------- | --------------------------------------------------------------------- |
| `id`        | string         | Order id.                                                             |
| `reference` | string         | Order number buyers see, like `SO-000142`.                            |
| `status`    | string         | See [statuses](#order-statuses).                                      |
| `total`     | integer        | Order total as priced (see `taxLabel`), in minor units of `currency`. |
| `currency`  | string         | Fixed when the order was placed.                                      |
| `poNumber`  | string or null | The buyer’s purchase order number.                                    |
| `createdAt` | string         | When it was placed.                                                   |
| `customer`  | string         | Customer name. Get the account code from `GET /orders/{id}`.          |

## Get one order

**GET** `/api/v1/orders/{id}`

The order with its lines and status history.

| Name | In   | Type   | Required | Description |
| ---- | ---- | ------ | -------- | ----------- |
| `id` | path | string | yes      | Order id.   |

```bash
curl https://tradecatalog.app/api/v1/orders/5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```js
const res = await fetch(`https://tradecatalog.app/api/v1/orders/${orderId}`, {
  headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})
const { data: order } = await res.json()
```

```json
{
  "data": {
    "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
    "customerAccountId": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
    "placedBy": "Fq3L0cZ7tN1pW9aYx2Rk",
    "reference": "SO-000142",
    "poNumber": "PO-88231",
    "notes": "Deliver to the Hebden Road site, gate 2.",
    "status": "new",
    "cancelReason": null,
    "currency": "GBP",
    "total": 31460,
    "createdAt": "2026-09-27T07:48:12.000Z",
    "updatedAt": "2026-09-27T07:48:12.000Z",
    "customer": { "name": "Oakfield Joinery", "accountCode": "OAK01" },
    "lines": [
      {
        "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
        "orderId": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
        "productId": "3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c",
        "sku": "HX-8510",
        "name": "Hex bolt M10 x 50 zinc",
        "unitLabel": "Box of 100",
        "qty": 20,
        "unitPrice": 1573,
        "listPrice": 1850,
        "priceLabel": "Brand discount −15%",
        "lineTotal": 31460
      }
    ],
    "events": [
      {
        "status": "new",
        "note": null,
        "createdAt": "2026-09-27T07:48:12.000Z",
        "actor": "Sam Patel"
      }
    ]
  }
}
```

| Field                                      | Description                                                                                                                                  |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `customerAccountId`                        | The customer’s id.                                                                                                                           |
| `placedBy`                                 | Id of the person who entered it. `source` distinguishes buyer and staff orders.                                                              |
| `notes`                                    | The buyer’s delivery or order notes.                                                                                                         |
| `cancelReason`                             | Why it was canceled, if it was.                                                                                                              |
| `lines`                                    | What was ordered at what price. A snapshot: later price changes never alter it. `productId` is `null` if the product has since been deleted. |
| `lines[].priceLabel`                       | The rule that set the price, as on [Prices](/docs/api/prices#the-price-object).                                                              |
| `lines[].priceLabel` is `Price on request` | The product has no price: `unitPrice` and `lineTotal` are `0` and the line isn’t in `total`. The supplier confirms its price.                |
| `events`                                   | Status history, oldest first. `actor` is the person’s name, or `null`.                                                                       |

## Update or correct an order

**PATCH** `/api/v1/orders/{id}`

Changes an order’s status. Returns the updated order in the same shape as `GET /orders/{id}`.

- Normal forward moves follow the fulfillment stages above. Other moves require a private correction reason; a repeated current status makes no change.
- Canceling needs a `note`, which is shown to the buyer as the reason.
- Status changes may email customer contacts, including the public note. A generated email does not prove delivery.
- The change is recorded in the order’s history against the person who made the key.

| Name     | In   | Type   | Required | Description                                      |
| -------- | ---- | ------ | -------- | ------------------------------------------------ |
| `id`     | path | string | yes      | Order id.                                        |
| `status` | body | string | yes      | The new status.                                  |
| `note`   | body | string | no       | Up to 1,000 characters. Required when canceling. |

```bash
curl -X PATCH https://tradecatalog.app/api/v1/orders/5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "dispatched", "note": "DPD tracking 1554 2210 0931" }'
```

```js
const res = await fetch(`https://tradecatalog.app/api/v1/orders/${orderId}`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ status: 'acknowledged' }),
})
const { data: order } = await res.json()
```

| Status | Code                 | When                                                                                                             |
| ------ | -------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request`    | The move isn’t allowed, e.g. “An order that is received can’t be marked completed”, or canceling without a note. |
| 403    | `insufficient_scope` | The key is read-only.                                                                                            |
| 404    | `not_found`          | No such order in this workspace.                                                                                 |

Retrying a `PATCH` that already succeeded returns `400 invalid_request`, because the order has moved on. Check the order’s status before treating that as a failure.

## Not available

The API can’t place orders for a buyer, or change an order’s lines, quantities or prices. Buyers place orders in their trade portal; changes to an order are agreed with the customer and invoiced in your own system.

Order lists also include `paymentStatus`: null for usual account terms, or the optional Stripe payment status. Fulfillment status is separate and never proves payment. The order detail page is the place to review the final confirmed total and approve payment. The API cannot create or approve a payment.

`internalNote` (2,000 characters) and `correctionReason` (1,000 characters) are staff-only history fields. `note` remains public, including old notes. Send `expectedRevision` to reject a concurrent edit with an invalid request. Staff API reads include private history; buyer tools and portal reads exclude staff identities and private fields. Canceling still requires a public reason and withdrawal or full refund of an outstanding payment.
