# Own-fleet pickup and delivery

This playbook describes the local delivery API of a business account: orders that the business's own drivers deliver to a recipient (`type` `D`) or collect from a sender (`type` `P`). One set of endpoints quotes, creates, labels, tracks and cancels both kinds of stop, and webhooks report each change to your system. It is written for developers of order-management systems, ERPs and online stores that dispatch work to the business's own fleet.

## 1. What you can build

The examples below follow one business: **Farine & Fils**, a bakery supplier with a depot at 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), that delivers wholesale orders across the island of Montreal and collects the empty bread crates its customers return. A typical delivery is one 12 kg crate stack of 60 × 40 × 30 cm for Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), under the wholesale order `WHS-20931`. A typical pickup is one stack of empty crates, 4 kg, from Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), under the reference `CRT-20931`.

- **Wholesale orders sent to dispatch from the ERP.** Each confirmed wholesale order becomes a delivery order with the café's morning delivery window, and the ERP stores the returned tracking number on the order line.
- **Crate return pickups.** When a customer reports empty crates, the ERP creates a pickup order for the customer's address, and a driver collects the crates on the next route.
- **Depot label printing.** The ERP downloads the label PDF of each order and prints it at the loading dock, so every crate stack carries its tracking barcode.
- **A customer portal with live status.** Each café sees the status of its deliveries and pickups, with the proof of delivery, fed by webhooks instead of polling.

## 2. What this playbook covers

Use this playbook when the business's own drivers carry the order: deliveries from the depot and pickups from a customer's address, created one at a time or in batches through the `/api/v1/client/...` and `/api/v1/orders/...` endpoints.

For new integrations, Uniorder (`/api/v1/uniorder/...`) is the recommended single entry point: it offers the same own-fleet deliveries through one API, together with carrier labels, from a single quote. See **Uniorder: one API for every shipment** for the overview and **Quote and order in one flow** for its step-by-step requests. The endpoints in this playbook remain available and unchanged for integrations built on them.

Use **Carrier labels** when a parcel is shipped by an external carrier under a label bought through the platform. Use **Shipping services** for orders a customer account books against the services of a business, and **Storage and ship-out** for goods kept in a warehouse and shipped out on request; Uniorder does not apply to those two.

## 3. Before you start

- **Account.** Use a business (client) account, or an employee account of the business, with API permission. Creating orders additionally requires the place-order permission; without it, `POST /api/v1/client/orderCreate` returns `401`.
- **Service area.** The delivery or pickup address must be inside an active region of the business. Use in-area addresses such as the ones in this playbook for tests.
- **Test data.** Use test references such as `WHS-20931` and `CRT-20931`, and cancel the test orders at the end (step 12).
- **Tokens.** Request the access token from your server and keep it there. Never send it to a browser or a mobile app.
- **Placeholders.** Replace `YOUR_HOST` with the API host of your environment and `ACCESS_TOKEN` with the token from step 4.
- **Units.** `weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in. Both default to `1`.

## 4. Authenticate

Every call in this playbook, except public tracking, is made on behalf of the business account. Log in once from your server, store the returned token and send it on every request.

**REST:** `POST /api/v1/user/login` — [REST handbook](/api/documentation#/paths/v1-user-login/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/user/login \
  -H "Content-Type: application/json" \
  -d '{"email":"dispatch@farineetfils.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
}
```

- `access_token`: place it in the header of every later request:

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL uses the same header on `POST /api/graphql`.

**GraphQL:** `userLogin` ([GraphQL handbook](/api/graphql/documentation#/user/userLogin))

**Verification:** login returns `access_token`. Subsequent requests without this token return `401`.

## 5. Quote a delivery or a pickup (optional)

A quote shows the price of a stop before the order exists, for example to display the delivery charge on a wholesale invoice. It creates nothing, and creating an order does not require a prior quote. Set `type` to `D` (delivery) or `P` (pickup); `to_postcode` is the postcode of the stop.

**REST:** `POST /api/v1/orders/rate` — [REST handbook](/api/documentation#/paths/v1-orders-rate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "D",
    "from_postcode": "H4R2N6",
    "from_country": "CA",
    "to_postcode": "H2V4G7",
    "to_country": "CA",
    "packages": [{
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "18.50",
    "tax_details": [
      { "tax_name": "GST", "tax_rate": "5.00", "tax": "0.93" },
      { "tax_name": "QST", "tax_rate": "9.975", "tax": "1.85" }
    ]
  }
}
```

- `shipping_price`: the price of the stop before tax. An empty price means the postcode is not in an active region, or the rate card has no row for it.
- `price_details.tax_details`: the taxes the order will carry; show them on the invoice line.
- `currency`: the currency of every amount in the response.

To quote the crate pickup, send the same request with `"type": "P"`, `"to_postcode": "H4G1V5"` and the crate stack's weight and size.

**GraphQL:** `ordersRate` ([GraphQL handbook](/api/graphql/documentation#/orders/ordersRate)). The result is a JSON scalar and takes no selection set.

```graphql
mutation {
  ordersRate(
    type: "P"
    from_postcode: "H4R2N6"
    from_country: "CA"
    to_postcode: "H4G1V5"
    to_country: "CA"
    packages: [{ weight: 4, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Verification:** `result` is `true` and `shipping_price` is a number for both `type` `D` and `type` `P`. Creating an order does not depend on this step.

## 6. Create a delivery order

Each confirmed wholesale order becomes one delivery order. The ERP stores the returned `id` and `tracking_number` on its order line; every later call uses one of them.

**REST:** `POST /api/v1/client/orderCreate` — [REST handbook](/api/documentation#/paths/v1-client-orderCreate/post)

Send an `Idempotency-Key` header, unique per wholesale order, so that a retry after a timeout cannot create a second order.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: whs-20931-delivery" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "WHS-20931",
    "name": "Marie Tremblay",
    "company_name": "Café Lumière",
    "telephone": "5145550142",
    "email": "commandes@cafelumiere.example",
    "address_1": "5400 Avenue du Parc",
    "city": "Montréal",
    "province": "QC",
    "postcode": "H2V4G7",
    "country": "Canada",
    "schedule_date": "2026-10-02",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "packages": 1,
    "packagesDetail": [{
      "ref": "WHS-20931-1",
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "delivery_instruction": "Deliver to the back door on Rue Saint-Viateur"
  }'
```

```json
{
  "result": true,
  "id": 12345,
  "ref": "WHS-20931",
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": { "shipping_fee": "18.50" },
  "tracking_number": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012", "external_tracking_number": "" }
  ]
}
```

| Field | Meaning |
|---|---|
| `type` | `D` delivery, or `P` pickup |
| `need_pick_up` | `0` — the goods are already at the depot. `1` — a driver must collect the parcel |
| `ref` | External reference used for lookup and reconciliation |
| `name` / address | Delivery: recipient. Pickup: collection stop |
| `schedule_date`, `time_window_start`, `time_window_end` | Delivery date (`Y-m-d`) and the window the stop must be served in (`Y-m-d H:i:s`) |
| `packagesDetail` | One entry per package; `ref` identifies the package in your system |
| `auto_deduplication` | `1` refuses a second package with the same package `ref` |

In the response:

- `id`: the order id; store it for the order detail and the cancel call.
- `tracking_number`: one tracking number per package; print and track with these.
- `warning`: present when the order was created with a notice, for example an address outside the delivery area that the business keeps or holds. A kept out-of-area order can return `shipping_price: null`.

**GraphQL:** `clientOrderCreate` ([GraphQL handbook](/api/graphql/documentation#/client/clientOrderCreate)). The result is a JSON scalar with the same body as the REST response.

```graphql
mutation {
  clientOrderCreate(
    type: "D"
    need_pick_up: 0
    ref: "WHS-20931"
    name: "Marie Tremblay"
    company_name: "Café Lumière"
    telephone: "5145550142"
    address_1: "5400 Avenue du Parc"
    city: "Montréal"
    province: "QC"
    postcode: "H2V4G7"
    country: "Canada"
    packages: 1
    packagesDetail: [{ ref: "WHS-20931-1", weight: 12, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Verification:** send the same body with the same `Idempotency-Key` again. The response carries the same `id`, and no second order is created.

## 7. Create a pickup order

A pickup order sends a driver to collect goods at an address; here, the empty crates at Épicerie Wellington. It uses the same endpoint as a delivery: the address is the collection stop, `type` is `P` and `need_pick_up` is `1`.

**REST:** `POST /api/v1/client/orderCreate` — [REST handbook](/api/documentation#/paths/v1-client-orderCreate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crt-20931-pickup" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "CRT-20931",
    "name": "Luc Gagnon",
    "company_name": "Épicerie Wellington",
    "telephone": "5145550187",
    "email": "luc@epiceriewellington.example",
    "address_1": "4100 Rue Wellington",
    "city": "Verdun",
    "province": "QC",
    "postcode": "H4G1V5",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "CRT-20931-1",
      "weight": 4,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Empty crates are stacked at the loading door"
  }'
```

```json
{
  "result": true,
  "id": 12346,
  "ref": "CRT-20931",
  "shipping_price": "12.00",
  "currency": "CAD",
  "tracking_number": ["SR123456789029"],
  "packages": [
    { "id": 67891, "ref": "CRT-20931-1", "tracking_number": "SR123456789029", "external_tracking_number": "" }
  ]
}
```

- `id` and `tracking_number`: store them against the crate return, as for a delivery.
- `pickup_instruction`: shown to the driver at the collection stop; `delivery_instruction` is its counterpart on a delivery.

**Verification:** the order detail (step 8) shows `type` `P` and `need_pickup` `1` for this order.

## 8. Read the order

The order detail confirms what was stored and returns the current status; the list endpoint lets the ERP reconcile its own records with the platform.

**REST:** `GET /api/v1/orders/{orderId}` — [REST handbook](/api/documentation#/paths/v1-orders-orderId/get)

```bash
curl https://YOUR_HOST/api/v1/orders/12345 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "business_name": "Farine & Fils",
  "order": {
    "id": 12345,
    "ref": "WHS-20931",
    "type": "D",
    "need_pickup": 0,
    "orders_status_id": 2,
    "name": "Marie Tremblay",
    "address_1": "5400 Avenue du Parc",
    "postcode": "H2V4G7",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "shipping_price": "18.50"
  },
  "tracking_numbers": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012" }
  ]
}
```

- `order.orders_status_id`: the order status; `2` is New, `12` is Cancelled.
- `order.type` and `order.need_pickup`: confirm that the stop was stored as a delivery or a pickup.
- `tracking_numbers`: the tracking numbers of the order's packages.

**REST:** `GET /api/v1/orders/list` — [REST handbook](/api/documentation#/paths/v1-orders-list/get)

```bash
curl "https://YOUR_HOST/api/v1/orders/list?page=1&per_page=50" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

The list returns every order of the account, newest first, each with its packages and item lines. Pass `page` and `per_page` together to paginate (`per_page` max 1000); without them the newest 1000 orders are returned with a `truncated` flag.

**GraphQL:** `orders` ([GraphQL handbook](/api/graphql/documentation#/orders/orders)) for one order and `ordersList` ([GraphQL handbook](/api/graphql/documentation#/orders/ordersList)) for the list. Both return a JSON scalar.

```graphql
query {
  orders(orderId: "12345")
}
```

**Verification:** the order belongs to the authenticated account, `ref` matches the value sent at create, and `tracking_numbers` matches the create response.

## 9. Print the local label

The label carries the tracking barcode that the driver scans at the depot and at the stop. Print one label per package and attach it to the crate stack.

**REST:** `POST /api/v1/shipping/getShippingLabel` — [REST handbook](/api/documentation#/paths/v1-shipping-getShippingLabel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/shipping/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "SR123456789012",
    "type": "TRACKING_NUMBER",
    "base64": 1,
    "hide_sender_address": 0,
    "hide_receiver_address": 0
  }'
```

```json
"JVBERi0xLjcKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZwo..."
```

- `type`: how `id` is read: `TRACKING_NUMBER` (default), `ORDER_ID` or `REF`.
- `base64`: `0` (default) streams the PDF. `1` makes the whole response body a top-level JSON string holding the base64 PDF, not an object with a `pdf_data` field. Call `POST /api/v2/shipping/getShippingLabel` — [REST handbook](/api/documentation#/paths/v2-shipping-getShippingLabel/post) instead to receive the label inside a regular JSON object.
- `packages`: optional; the number of labels to print. A value different from the order's package count updates the order.
- `hide_sender_address` / `hide_receiver_address`: `1` leaves that address blank on the label.

**GraphQL:** `shippingGetShippingLabel` ([GraphQL handbook](/api/graphql/documentation#/shipping/shippingGetShippingLabel)). `shippingGetShippingLabelV2` ([GraphQL handbook](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)) always returns JSON (`pdf_data`).

**Verification:** the decoded PDF opens. The delivery label shows Café Lumière's address; the pickup label shows Épicerie Wellington's address. A hidden address is blank on the label.

## 10. Track the order

Public tracking returns the event timeline of a package. It needs no access token, so a customer portal can show it directly; the proof of delivery or pickup comes with it.

**REST:** `GET /api/v1/tracking/{trackingNumber}` — [REST handbook](/api/documentation#/paths/v1-tracking-trackingNumber/get)

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012
```

```json
{
  "result": true,
  "postcode": "H2V4G7",
  "deliveried": false,
  "returntosender": false,
  "rejectedbyrecipient": false,
  "data": [
    {
      "tracking_event_status_id": 100,
      "description": "Order information submitted",
      "updated_at_localized": "2026-10-01 16:42:10"
    }
  ],
  "proofs": []
}
```

The same URL accepts your `ref` when it was stored as an external number.

Branch on `tracking_event_status_id`, not on `description`; that string follows `Accept-Language`.

| `tracking_event_status_id` | Side | Meaning |
|---|---|---|
| `100` | both | Order received |
| `300` / `301` | delivery | In facility |
| `450` | delivery | Out for delivery |
| `500` | delivery | Delivered |
| `501` | delivery | Delivery failed, needs a new plan |
| `460` | pickup | Out for pickup |
| `510` | pickup | Picked up |
| `512` | pickup | Pickup failed, try later |
| `513` | pickup | Pickup problem |

- `data`: newest first; the first row is the current state.
- `deliveried`: `true` after `500`.
- `proofs[]`: on `500` or `510`, may carry `type` `1` (signature) or `2` (photo), with `file_id` and `signed_url`. A photo uploaded after that event is not in this payload; subscribe to `pod.files_updated` (step 11).

**GraphQL:** `trackingPublic` ([GraphQL handbook](/api/graphql/documentation#/tracking/trackingPublic)). The result is typed and needs a selection set.

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    returntosender
    rejectedbyrecipient
    postcode
    data {
      tracking_event_status_id
      tracking_event_key
      description
      updated_at_localized
    }
    proofs { file_id type full_url signed_url }
  }
}
```

**Verification:** right after create, the newest event is `100` and `deliveried` is `false`. An unknown number returns `result: false` with `404`; display a not-found state and do not synthesize tracking events.

## 11. Receive webhooks

Webhooks push each change to your server, so the ERP and the customer portal stay current without polling. Register the callback URLs this flow needs:

| Setting | Event | Use |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Persist `id` and `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | Customer-facing status |
| `tracking_event_webhook_url` | `tracking.event` | Pickup or delivery timeline |
| `pod_files_webhook_url` | `pod.files_updated` | Photo or signature after pickup or delivery |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | A cancel you sent was refused |
| `order_create_async_postback_url` | `order.create_async` | Result of an asynchronous batch (step 13) |

**REST:** `PUT /api/v1/webhook-settings` — [REST handbook](/api/documentation#/paths/v1-webhook-settings/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/webhook-settings \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "pod_files_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_cancel_failed_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "order_status_change_webhook_url",
    "tracking_event_webhook_url",
    "pod_files_webhook_url",
    "order_cancel_failed_webhook_url",
    "webhook_sign_secret"
  ],
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_verify_ssl": 1
  }
}
```

- `changed_keys`: the settings this call changed.
- `settings.webhook_sign_secret`: returned masked; keep the full value on your server only.

**GraphQL:** `webhookSettingsUpdate` ([GraphQL handbook](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)).

On the receiving side, verify the **v2** signature over the raw body: `HMAC_SHA256(timestamp + "." + raw_body, secret)` compared with `X-Webhook-Signature-V2`, where the timestamp is `X-Webhook-Timestamp`. Deduplicate on `X-Webhook-Event-Id`. Answer **2xx in under 3 seconds** and process the event afterwards.

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_V2'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, $sharedSecret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}
```

**Verification:** create one test order and receive `order.created` with the same `id` and `tracking_number`. The receiver rejects an invalid signature with `401`, and a second delivery of the same `X-Webhook-Event-Id` is not processed twice.

## 12. Cancel an order

Cancel an order when the wholesale order is withdrawn or the crate pickup is no longer needed. The call is idempotent: cancelling an order that is already cancelled succeeds again.

**REST:** `POST /api/v1/orders/cancel` — [REST handbook](/api/documentation#/paths/v1-orders-cancel/post) — send exactly one of `order_id`, `tracking_number`, `external_tracking_number`.

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tracking_number":"SR123456789012"}'
```

```json
{
  "result": true,
  "id": 12345,
  "message": "Order 12345 has been cancelled successful.",
  "already_cancelled": false
}
```

- `result`: `true` when the order is cancelled.
- `already_cancelled`: `true` when the order was cancelled before this call; treat it as success.
- `code`: present when the cancel is refused; see step 14.

**GraphQL:** `ordersCancel` ([GraphQL handbook](/api/graphql/documentation#/orders/ordersCancel)). The result is typed and needs a selection set.

```graphql
query {
  ordersCancel(tracking_number: "SR123456789012") {
    result
    id
    message
    already_cancelled
    code
  }
}
```

**Verification:** the order detail shows `orders_status_id` `12`, and the same cancel returns `already_cancelled: true`. When a cancel is refused, `order.cancel_failed` is sent to `order_cancel_failed_webhook_url`.

## 13. Create orders in batches (optional)

The ERP can send the day's wholesale orders and crate pickups in one request. Each row takes the same fields as steps 6 and 7 and may be `type` `D` or `P`.

**REST:** `POST /api/v1/client/batchOrderCreate` — [REST handbook](/api/documentation#/paths/v1-client-batchOrderCreate/post) — answers when every row has been processed.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/batchOrderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-2026-10-01" \
  -d '{
    "per_order_transaction": 1,
    "orders": [
      {
        "type": "D",
        "need_pick_up": 0,
        "ref": "WHS-20932",
        "name": "Sophie Roy",
        "company_name": "Boulangerie du Marché",
        "telephone": "5145550163",
        "address_1": "7070 Avenue Henri-Julien",
        "city": "Montréal",
        "province": "QC",
        "postcode": "H2S3S3",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "WHS-20932-1", "weight": 10, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      },
      {
        "type": "P",
        "need_pick_up": 1,
        "ref": "CRT-20932",
        "name": "Luc Gagnon",
        "company_name": "Épicerie Wellington",
        "telephone": "5145550187",
        "address_1": "4100 Rue Wellington",
        "city": "Verdun",
        "province": "QC",
        "postcode": "H4G1V5",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "CRT-20932-1", "weight": 4, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      }
    ]
  }'
```

```json
[
  { "result": true, "id": 12347, "ref": "WHS-20932", "tracking_number": ["SR123456789036"], "packages": [{ "id": 67892, "ref": "WHS-20932-1", "tracking_number": "SR123456789036", "external_tracking_number": "" }] },
  { "result": true, "id": 12348, "ref": "CRT-20932", "tracking_number": ["SR123456789043"], "packages": [{ "id": 67893, "ref": "CRT-20932-1", "tracking_number": "SR123456789043", "external_tracking_number": "" }] }
]
```

- Each row has its own `result`; match it to your order line by `ref`. A refused row carries `message` and `skipped_ref`, and may carry `code` (for example `INSUFFICIENT_BALANCE` or `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction`: `1` commits each row on its own, so one failed row cannot roll back the others.
- Batches of more than 100 orders receive an `X-Batch-Size-Warning` response header; send them to the asynchronous endpoint.

**REST:** `POST /api/v1/client/batchOrderCreateAsync` — [REST handbook](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — takes the same body and returns a job identifier immediately:

```json
{ "message": "Order batch created in async,please check later.", "asyncId": 28 }
```

Poll `GET /api/v1/client/async/{id}` — [REST handbook](/api/documentation#/paths/v1-client-async-id/get) — with the `asyncId`, or receive `order.create_async` at `order_create_async_postback_url`. The job result is the same per-row list as the synchronous endpoint.

```bash
curl https://YOUR_HOST/api/v1/client/async/28 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

**GraphQL:** `clientBatchOrderCreate` ([GraphQL handbook](/api/graphql/documentation#/client/clientBatchOrderCreate)), `clientBatchOrderCreateAsync` ([GraphQL handbook](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)) and `clientAsync` ([GraphQL handbook](/api/graphql/documentation#/client/clientAsync)).

**Verification:** a batch of two rows returns two results, each with its `ref`. The asynchronous job returns the same rows once it has run.

## 14. Handling errors

| Situation | HTTP status | Code | What the integration does |
|---|---|---|---|
| A required field is missing or malformed (create) | 400 | `VALIDATION_FAILED` | Correct the field named in `message` and send the request again. |
| The account balance does not cover the order | 400 | `INSUFFICIENT_BALANCE` | Read `insufficient_balance` (required, available, shortfall); recharge, then retry. No order was created. |
| The address is outside the service area and the business deletes such orders | 400 | `OUT_OF_DELIVERY_AREA` | Submit an address inside the service area. No order was created. |
| A package `ref` or external tracking number already exists (with deduplication on) | 200 (`result` `false`), or 409 with `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | Read `exist_package_ref` and link the existing order instead of creating a new one. |
| An `Idempotency-Key` is reused with a different body | 409 | `IDEMPOTENCY_CONFLICT` | Use a new key for a different request. |
| A request with the same `Idempotency-Key` is still running | 409 | `IDEMPOTENCY_IN_PROGRESS` | Wait, then retry with the same key. |
| Cancel without an order identifier | 400 | `MISSING_IDENTIFIER` | Send one of `order_id`, `tracking_number`, `external_tracking_number`. |
| Cancel of an order that does not exist | 400 | `ORDER_NOT_FOUND` | Check the stored `id` or tracking number. |
| The number matches more than one active order | 409 | `MULTIPLE_ORDERS_MATCHED` | Cancel by `order_id`, using one of `matched_order_ids`. |
| The order belongs to another account | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Cancel with the account that created the order. |
| The order's status no longer allows a cancel | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Leave the order as it is; handle the return separately. |
| The order is held by a third-party carrier that cannot cancel it | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` or `THIRD_PARTY_CANCEL_FAILED` | The order is unchanged; contact the business. |
| The token is missing or expired, or the account may not place orders | 401 | — | Log in again; check the account's permissions. |

## Test checklist

Use test references such as `WHS-20931` and `CRT-20931`:

- [ ] (Optional) The quote returns a price for an in-area postcode with `type` `D`.
- [ ] (Optional) The quote returns a price for an in-area postcode with `type` `P`.
- [ ] Delivery create returns `id` + `tracking_number`; the same `Idempotency-Key` does not create a second order.
- [ ] Pickup create returns `id` + `tracking_number`; the order detail shows `type` `P` and `need_pickup` `1`.
- [ ] The order detail and the list show both orders under this account.
- [ ] The local label PDF opens and shows the recipient or the collection address.
- [ ] Public tracking returns the timeline without a token; the newest event is `100`.
- [ ] `order.created` arrives and its v2 signature verifies.
- [ ] Cancel returns `result: true`, and a second cancel returns `already_cancelled: true`.
- [ ] A batch of one delivery and one pickup returns two results, each with its `ref`.
