# Shipping services

The shipping services API lets a customer account book the shipping services that its logistics provider has configured and assigned to it. The customer's own system lists the services it may use, loads the rules of one service, prices a shipment, creates the shipping order, pays it from the account balance, and follows the shipment until delivery. This playbook is for developers who connect an importer's, a merchant's or a wholesaler's system to the logistics provider that serves it.

## 1. What you can build

All examples in this playbook use one scenario. **Harbourline Imports Inc.**, a tea importer in Toronto, holds a customer account with its logistics provider. The provider offers the service `intl_express` (International Express) from its Toronto Hub warehouse (warehouse id `7`). Harbourline drops two cartons of sample tea at the Toronto Hub for a distributor in Seattle, under its purchase order `HLI-PO-1058`.

- **Booking from the purchase-order system.** When a purchase order is released, Harbourline's system prices the shipment on `intl_express`, creates the shipping order with the purchase order number as its reference, and pays it from the prepaid account balance without anyone opening the provider's portal.
- **A price check before commitment.** The buyer at Harbourline sees the freight, surcharges, tax and total for the two cartons before the shipment is booked, and a shipment that the service cannot price is stopped before an order exists.
- **Shipment status inside the ERP.** Each carton's tracking number is stored against the purchase order; webhooks move the order status and tracking timeline into the ERP, and a nightly job reconciles against the order list.
- **Controlled changes.** An unpaid booking is corrected in place, and a booking that is no longer needed is cancelled with the paid amount returned to the account credit.

## 2. What this playbook covers

Use this family when the caller is a **customer** of the logistics business and books one of the business's own shipping services: the business sets the price plan, the warehouses, the surcharges and the packaging, and assigns the services to the customer. The customer only sees and books the services assigned to it.

Use another family in these cases:

- The caller is the logistics business itself (a business/client account) and books same-day or local pickups and deliveries on its own fleet: read **Local delivery**.
- The caller buys carrier labels (for example UPS or FedEx) at the account's negotiated rates: read **Carrier labels**.
- The customer stores goods at the provider's warehouse and ships them out from stock: read **Storage and shipout**.

**Uniorder: one API for every shipment** (`/api/v1/uniorder/...`) is the recommended single entry point for new integrations of local delivery and carrier labels. Uniorder does not cover shipping services: shipping-service orders are created and managed only through the `/api/v1/customer/shipping-orders/...` endpoints described here.

## 3. Before you start

- **Account type.** A **customer** account of the logistics business, with **API permission** enabled by the business. A business/client or employee account cannot log in through the customer login below.
- **Service assignment.** The business must assign at least one active shipping service to the customer. A customer with no assigned service receives an empty service list.
- **Test data.** Agree with the business on a test service code, a test warehouse and a small prepaid balance on the test account. Use a reference such as `HLI-PO-1058` or `DEV-SHIP-001` so that test orders are easy to find and cancel.
- **Token handling.** Call the API from your server only. Keep the password and the access token out of browsers and mobile clients. The token expires one week after login (`expires_at`); log in again before it expires.
- **Placeholders.** Replace `YOUR_HOST` with the host name of the logistics business and `ACCESS_TOKEN` with the token returned by the login step.
- **JSON errors.** Send `Accept: application/json` on every request so that validation errors return JSON instead of a redirect.

## 4. Log in as the customer

The login exchanges the customer's email and password for a bearer token. Every later call in this playbook sends that token.

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

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

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2026-10-05 09:15:00",
  "expires_timestamp": 1791206100,
  "name": "Harbourline Imports Inc."
}
```

- `access_token`: send it on every request as `Authorization: Bearer ACCESS_TOKEN`. GraphQL uses the same header on `POST /api/graphql`.
- `expires_at` / `expires_timestamp`: schedule a new login before this time.

**Verification:** the response has `result: true` and an `access_token`. A request without the token returns `401`; a login with an account that is not a customer, or that has no API permission, also returns `401`.

## 5. List the services assigned to the customer

The service list tells the integration which service codes it may book and whether each service accepts a warehouse drop-off, a pickup, or both. Store the `service_code`; every later service call uses it.

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "services": [
      {
        "id": 12,
        "service_code": "intl_express",
        "name": { "en": "International Express" },
        "offer_pickup": true,
        "allow_warehouse_delivery": true,
        "support_multi_package": true,
        "allow_special_requirements": false,
        "allow_purchase_supplies": true,
        "send_confirmation_email": true,
        "warehouses": [{ "id": 7, "name": "Toronto Hub" }]
      }
    ]
  }
}
```

- `service_code`: the path parameter of every later service call.
- `offer_pickup` / `allow_warehouse_delivery`: the allowed values of `origin_type` (`pickup` / `warehouse`).
- `support_multi_package`: whether one order may carry more than one package line.
- An empty `services` array means no service is assigned to this customer.

**GraphQL:** `customerShippingOrderServices` ([GraphQL handbook](/api/graphql/documentation#/customer/customerShippingOrderServices))

```graphql
query CustomerShippingOrderServices {
  customerShippingOrderServices
}
```

**Verification:** the list contains at least one service and you stored its `service_code` (in this playbook: `intl_express`).

## 6. Load the service configuration

The configuration returns everything the order form of one service needs: the warehouses that accept drop-offs, the selectable surcharges, the packaging and supplies catalogue, the units, and the countries the service can pick up from and deliver to. Validate your order data against it before you price or create anything.

**REST:** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/config` — [REST handbook](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--config/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/config \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "service": {
      "service_code": "intl_express",
      "offer_pickup": true,
      "allow_warehouse_delivery": true,
      "allow_special_requirements": false,
      "allow_purchase_supplies": true,
      "weight_mode": 2,
      "volumetric_factor": 5000
    },
    "warehouses": [
      {
        "id": 7,
        "name": "Toronto Hub",
        "address_1": "250 Dundas St W",
        "city": "Toronto",
        "province": "ON",
        "country": "CA",
        "postcode": "M5T 2Z5",
        "telephone": "4165550100"
      }
    ],
    "surcharges": [],
    "has_surcharges": false,
    "packagings": [],
    "products": [],
    "weight_units": { "2": { "name": "weight_kg", "accuracy": 3 } },
    "dimension_units": { "2": { "name": "dimension_cm", "accuracy": 1 } },
    "delivery_allowed_countries": ["CA", "US"],
    "pickup_allowed_countries": ["CA"]
  }
}
```

- `warehouses[].id`: the `warehouse_id` to send when `origin_type` is `warehouse`. An id that is not in this list is refused at create time.
- `service.weight_mode`: which package fields the price plan requires: `0` actual weight (weight), `1` volumetric weight (length, width and height), `2` billable weight (both). `null` means the service is priced by hand. Send weight and all three dimensions to satisfy every mode.
- `delivery_allowed_countries` / `pickup_allowed_countries`: reject a destination or pickup country outside these lists before calling the estimate.
- `surcharges[].id`, `packagings[].id`, `products[].id`: the ids to use for optional surcharges, packaging and supplies purchases.
- `weight_units` / `dimension_units`: package units are sent as numbers. Send `weight_unit: 2` (kg) and `dimension_unit: 2` (cm), as all examples in this playbook do; both are also the defaults when the fields are omitted.

**GraphQL:** `customerShippingOrderServiceConfig` ([GraphQL handbook](/api/graphql/documentation#/customer/customerShippingOrderServiceConfig))

```graphql
query CustomerShippingOrderServiceConfig {
  customerShippingOrderServiceConfig(serviceCode: "intl_express")
}
```

**Verification:** `result` is `true` and, for a warehouse drop-off, `warehouses` contains the warehouse you intend to use. `403` means the service is not assigned to this customer; `404` means the service code does not exist or is inactive.

## 7. Estimate the price

The estimate prices the shipment with the service's price plan without writing anything. Show the total to the buyer, and do not create the order when the estimate reports a refusal.

**REST:** `POST /api/v1/customer/shipping-orders/services/{serviceCode}/estimate-price` — [REST handbook](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--estimate-price/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/estimate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_postcode": "98104",
    "delivery_country": "US",
    "packages": [{
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "needs_manual_quote": false,
    "shipping_fee": 118.40,
    "shipping_fee_total": 131.20,
    "fuel_surcharge": 12.80,
    "pickup_fee": null,
    "surcharges_total": 0,
    "sub_total": 131.20,
    "tax": { "total_tax": 17.06 },
    "total": 148.26,
    "currency": "CAD",
    "all_fees_calculated": true,
    "has_items_needing_quote": false,
    "unpriced_items": [],
    "refused": false,
    "refusal_message": null
  }
}
```

- `origin_type`: `warehouse` (the customer drops the goods at a warehouse; send `warehouse_id`) or `pickup` (the provider collects; send `pickup_postcode` and `pickup_country`). Use only a value that step 5 allows.
- `packages`: one line per identical group of packages; `quantity` multiplies the line.
- `total` and `currency`: the amount to show. `total` is `null` while any fee is not calculated.
- `needs_manual_quote` / `has_items_needing_quote`: the business prices the order by hand; the order can be created and is paid after the business sets the price.
- `refused` / `refusal_message`: the service refuses shipments it cannot price. Do not create the order; show `refusal_message` instead.
- Optional inputs: `surcharges`, `products` (a map of product id to quantity, honoured only when `allow_purchase_supplies` is true), `has_special_requirements`, `coupon_code`.

**Verification:** `result` is `true`, `refused` is `false`, and either `total` has a value or `needs_manual_quote` is `true`.

## 8. Create the shipping order

The create call books the shipment on the service. The integration stores the returned `id` against its own purchase order; every later call uses this id.

**REST:** `POST /api/v1/customer/shipping-orders/services/{serviceCode}/orders` — [REST handbook](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--orders/post)

Send an `Idempotency-Key` derived from your own stable id (here the purchase order number). A retry with the same key and the same body returns the first answer with `"replayed": true` and the header `Idempotency-Replayed: true`, and creates no second order.

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: hli-po-1058" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "note": "Two cartons of sample tea, dock door B",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "price_breakdown": { "total": 148.26, "currency": "CAD" },
    "promotion_id": null,
    "tracking_number": null
  }
}
```

- The body key for packages is `package` on create (it is `packages` on the estimate). Each line of `quantity` N becomes N packages, and each package receives its own tracking number.
- Required fields: `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`, `origin_type`, `package[].weight`; plus `warehouse_id` for `warehouse`, or `pickup_name`, `pickup_telephone`, `pickup_address_1`, `pickup_city`, `pickup_province`, `pickup_country`, `pickup_postcode` for `pickup`.
- Optional fields: `reference` (stored as the order's `reference_number`), `delivery_email`, `delivery_address_2`, `scheduled_date`, `time_window`, `note`, `special_requirements` (an array of text lines, honoured only when the service allows them), `products`, `surcharges`, `coupon_code`.
- `id`: store it. `status` `0` is Pending (awaiting payment).
- `total_price`: the amount that step 9 charges. It is `0` while the order awaits a manual quote.
- `tracking_number` at order level is `null`; the tracking numbers are on the packages and are read in step 10.
- The endpoint answers HTTP `201` on a new order.

**Verification:** the response has `result: true` and an `id`. Repeating the same request with the same `Idempotency-Key` returns the same `id` with `"replayed": true`.

## 9. Pay the order from the account balance

Shipping orders are paid in full from the customer's account balance. A paid order moves from Pending to Confirmed, and the provider starts handling it.

Read the amount first:

**REST:** `GET /api/v1/customer/shipping-orders/{id}/payment-info` — [REST handbook](/api/documentation#/paths/v1-customer-shipping-orders-id--payment-info/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "order_id": 9001,
    "currency": "CAD",
    "total_price": "148.26",
    "paid_amount": "0.00",
    "remaining_balance": "148.26",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "shortfall": 0,
    "payment_options": [
      { "type": "remaining_balance", "amount": 148.26 }
    ]
  }
}
```

Then pay:

**REST:** `POST /api/v1/customer/shipping-orders/{id}/pay` — [REST handbook](/api/documentation#/paths/v1-customer-shipping-orders-id--pay/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/9001/pay \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"payment_type":"remaining_balance"}'
```

```json
{
  "result": true,
  "message": "Payment of $148.26 processed successfully.",
  "data": {
    "order_id": 9001,
    "status": 1,
    "amount_paid": "148.26"
  }
}
```

- `has_sufficient_balance` / `shortfall`: when the balance does not cover `remaining_balance`, top up the account before paying.
- `payment_type`: only `remaining_balance` is supported; the full remaining amount is always charged.
- `data.status` `1` is Confirmed.

**Verification:** the pay call returns `result: true` and `status` `1`, and a second `payment-info` call returns `400` because the order is fully paid. A pay call without sufficient balance returns `422` and charges nothing.

## 10. Read the order and track the packages

The detail call returns the current status and the tracking number of every package. Store the package tracking numbers against the purchase order; public tracking accepts each of them.

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 1,
    "status_name": "Confirmed",
    "can_edit": false,
    "can_cancel": true,
    "shipping_code": "K7RW2Q",
    "tracking_number": null,
    "reference_number": "HLI-PO-1058",
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_address": { "name": "Cascade Tea Distributors", "city": "Seattle", "country": "US" },
    "package_count": 2,
    "packages": [
      { "id": 55101, "description": "Sample tea carton", "tracking_number": "SR123456789012", "weight": 12, "weight_unit": 2 },
      { "id": 55102, "description": "Sample tea carton", "tracking_number": "SR123456789013", "weight": 12, "weight_unit": 2 }
    ],
    "total_price": 148.26
  }
}
```

- `status`: `0` Pending, `1` Confirmed, `2` In Transit, `3` Shipped, `4` Cancelled, `5` Failed, `6` Partially Picked Up, `7` Picked Up, `8` Processing.
- `can_edit` / `can_cancel`: whether step 12 is currently allowed.
- `packages[].tracking_number`: the numbers to store and to track.
- `shipping_code`: the code the warehouse drop-off screens accept; print it on the drop-off paperwork.

**GraphQL:** `customerShippingOrderShow` ([GraphQL handbook](/api/graphql/documentation#/customer/customerShippingOrderShow))

```graphql
query CustomerShippingOrderShow {
  customerShippingOrderShow(id: 9001) {
    result
    message
    data {
      id
      status
      status_name
      can_cancel
      reference_number
      packages {
        id
        tracking_number
        weight
      }
      total_price
    }
  }
}
```

To reconcile all orders of one service, for example in a nightly job, list them with a filter. The `id` filter matches the order id, a tracking number or the reference.

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

```bash
curl "https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders?id=HLI-PO-1058&created_at_from=2026-09-01&per_page=20" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

**GraphQL:** `customerShippingOrders` ([GraphQL handbook](/api/graphql/documentation#/customer/customerShippingOrders))

Public tracking needs no token and returns the event timeline of one package:

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

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012 \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 1,
      "otep_status": "received",
      "description": "Received at warehouse",
      "location_city": "Toronto",
      "updated_at": "2026-09-29 10:42:00"
    }
  ]
}
```

**GraphQL:** `trackingPublic` ([GraphQL handbook](/api/graphql/documentation#/tracking/trackingPublic))

```graphql
query TrackingPublic {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    message
    deliveried
    data {
      tracking_event_status_id
      otep_status
      description
      location_city
      updated_at
    }
  }
}
```

**Verification:** the detail returns this customer's order with one tracking number per package, and public tracking returns `result: true` for a package tracking number. Another customer's order id returns `404`.

## 11. Receive webhooks

Webhooks deliver order creation, status changes and tracking events to your server, so the integration does not need to poll. The customer account configures its own webhook URLs and signing secret.

When a shipping order is created, the provider also creates a linked pickup order for its dispatch team. The webhooks are sent for that linked order: its `ref` is `Shipping-Pickup-{shipping order id}` (for example `Shipping-Pickup-9001`), and each of its packages carries the shipping package tracking number in `external_tracking_number`. Match incoming events on these two fields.

| Setting | Event | What the integration does |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Link the event to the shipping order through `ref` and `packages[].external_tracking_number` |
| `tracking_event_webhook_url` | `tracking.event` | Append the event to the package timeline |
| `order_status_change_webhook_url` | `order.status_change` | Update the status shown in your system |

**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" \
  -H "Accept: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.harbourline-imports.example/hooks/order-created",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking",
    "order_status_change_webhook_url": "https://erp.harbourline-imports.example/hooks/status",
    "webhook_sign_secret": "hli-webhook-secret-7f2c9a1e5b"
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "tracking_event_webhook_url",
    "order_status_change_webhook_url",
    "webhook_sign_secret"
  ],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "*************************1e5b",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking"
  }
}
```

- Only the submitted keys change; an empty string clears a URL. `webhook_sign_secret` must be 16 to 255 characters, and no webhook is sent while the secret is empty.
- `recipient_type` is `customer` for a customer account.

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

```graphql
mutation WebhookSettingsUpdate {
  webhookSettingsUpdate(
    order_status_change_webhook_url: "https://erp.harbourline-imports.example/hooks/status"
    tracking_event_webhook_url: "https://erp.harbourline-imports.example/hooks/tracking"
  )
}
```

Verify the **v2** signature over the raw body: `HMAC_SHA256(timestamp + "." + raw_body, secret)` against `X-Webhook-Signature-V2`. 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:** after the settings call, one test create produces an `order.created` event whose `ref` is `Shipping-Pickup-{id}` for the new shipping order id, and the signature check passes.

## 12. Amend or cancel an order

An order can be corrected while it is Pending (before payment) and cancelled while it is Pending or Confirmed. A cancellation of a paid order returns the paid amount to the account credit.

To amend, send the complete order again with the same fields as step 8. The price is recalculated.

**REST:** `PUT /api/v1/customer/shipping-orders/{id}` — [REST handbook](/api/documentation#/paths/v1-customer-shipping-orders-id/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_address_2": "Suite 210",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "promotion_id": null,
    "coupon_code": null,
    "promotion_discount": null
  }
}
```

To cancel:

**REST:** `POST /api/v1/customer/shipping-orders/{id}/cancel` — [REST handbook](/api/documentation#/paths/v1-customer-shipping-orders-id--cancel/post)

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

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 4,
    "refund_amount": 148.26
  },
  "message": "Order cancelled. $148.26 refunded to your credit."
}
```

- `status` `4` is Cancelled. The linked pickup order is removed.
- `refund_amount`: the amount returned to the account credit; `0` for an unpaid order.
- Read `can_edit` and `can_cancel` from step 10 before offering these actions to users.

**Verification:** the cancel returns `status` `4` and the detail shows `status_name` `Cancelled`. A second cancel, or a cancel of an order that is In Transit or later, returns `403` with the message `This order can no longer be cancelled.`; an amend of a paid order returns `403`.

## 13. Handling errors

| Situation | HTTP status | Code | What the integration does |
|---|---|---|---|
| Missing, expired or invalid token; login with a non-customer account or without API permission | `401` | — | Log in again; if login itself fails, ask the business to check the account type and API permission |
| The service is not assigned to this customer | `403` | — | Re-read the service list (step 5) and book only assigned services |
| The API is called from a platform app session whose app has shipping orders disabled | `403` | `APP_CAPABILITY_DISABLED` | Ask the business to enable shipping orders for the app |
| Unknown or inactive service code; order id not found for this customer | `404` | — | Refresh the service list; check the stored order id |
| Required field missing or invalid | `422` | — | Read `errors` in the body, correct the fields, and send again |
| Origin type not offered by the service, or warehouse not in the service's list | `422` | — | Use an `origin_type` and `warehouse_id` from steps 5 and 6 |
| The service cannot price the shipment and refuses unpriced shipments | `422` | `unpriced_refused` | Nothing was created; show `message` and do not retry unchanged |
| Supplies ordered that are out of stock | `422` | — | Read `stock_shortages`, reduce the quantities, and send again |
| The same `Idempotency-Key` with a different body | `409` | `IDEMPOTENCY_CONFLICT` | Use a new key for a new order; never reuse a key for different content |
| A retry while the first request with that key is still processing | `409` | `IDEMPOTENCY_IN_PROGRESS` | Wait for `Retry-After` seconds, then retry with the same key and body |
| Pay without sufficient balance | `422` | — | Top up the account, then pay again |
| Payment info or pay on an order that is fully paid | `400` | — | Treat the order as paid; read the detail |
| Cancel after the order has left Pending or Confirmed | `403` | — | Show that the order can no longer be cancelled; contact the business |
| Amend after payment | `403` | — | Cancel and create a new order, or contact the business |
| Server error during estimate, create, pay or cancel | `500` | — | Retry once; for create, retry with the same `Idempotency-Key` |

## Test checklist

Use a test reference such as `DEV-SHIP-001` or `HLI-PO-1058`:

- [ ] Customer login returns `access_token`; a request without the token returns `401`.
- [ ] The service list is not empty and you stored one `service_code`.
- [ ] The config returns the warehouses, units and allowed countries for that service, and your form uses them.
- [ ] The estimate returns a `total` (or `needs_manual_quote: true`), and a refused shipment is not created.
- [ ] Create returns an `id`; the same `Idempotency-Key` with the same body returns the same `id` with `"replayed": true`.
- [ ] Pay succeeds and the status becomes Confirmed, or you confirmed that an insufficient balance returns `422` and charges nothing.
- [ ] The detail shows this customer's order with one tracking number per package, and public tracking finds each package.
- [ ] Webhooks are configured with a signing secret; a test create produces `order.created` with `ref` `Shipping-Pickup-{id}` and the signature check passes.
- [ ] Cancel of the test order returns `status` `4` and the expected `refund_amount`; a second cancel returns `403`.
