# Storage and ship-out

The storage and ship-out API lets a customer account of a warehouse business check goods into storage, pay for the storage period, and later ship stored packages out to its own buyers. It is intended for merchants and platforms that keep inventory in a third-party warehouse (3PL) and need to automate the storage booking, the stock view and the outbound shipments from their own systems. All calls run as the customer account, never as the warehouse business.

## 1. What you can build

The examples in this playbook follow one scenario. **Northwind Outdoor**, a seasonal online seller of winter gear, stores its winter stock at the **Toronto Hub** (warehouse `7`) of its 3PL from 1 November 2026 to 31 March 2027. When a buyer orders a carton of insulated jackets, Northwind ships that carton from stock to the buyer in Ottawa.

- **Seasonal storage booking.** The seller's back office quotes and books a storage period for each inbound carton before the goods leave the supplier, and pays the storage fee from its account balance.
- **Live stock view.** The seller's store or ERP lists the packages the warehouse has actually received and that are still available to ship, so only real stock is offered for fulfilment.
- **Order fulfilment from stock.** When a buyer places an order, the seller's system prices the outbound shipment, creates a ship-out request for the stored packages, pays it and records the tracking number for the buyer.
- **Status follow-up and correction.** The seller's system reads the state of each storage order and ship-out, follows the shipment through public tracking, and cancels a ship-out that is no longer needed while it is still allowed.

## 2. What this playbook covers

Use this playbook when the goods are already, or will be, stored in the business's warehouse and the shipment starts from that stock. The flow is: sign in → read the storage configuration → quote storage → create the storage order → pay → list packages in stock → list services and estimate the ship-out → create the ship-out → pay → read and track → webhooks → cancel.

Other playbooks fit other cases:

- **Uniorder: one API for every shipment** — the recommended single entry point (`/api/v1/uniorder/...`) for new integrations that book local delivery or carrier labels. Uniorder does **not** cover storage and ship-out; storage orders and ship-outs are created only through the customer endpoints in this playbook.
- **Shipping services** — a customer ships goods that are not in storage, using the business's shipping services.
- **Carrier labels** — a business buys carrier labels directly for its own parcels.
- **Local delivery** — a business books pickups and deliveries with its own fleet.

## 3. Before you start

- **Account type.** A **customer** account of the warehouse business (the business that operates the warehouse is the service provider). A business (client) account token does not work on `/api/v1/customer/...` endpoints.
- **Permissions.** The customer account needs API access. Storage endpoints also require the storage capability; ship-out endpoints require the business to have enabled ship-out (or consolidation) for this customer, otherwise they answer `403`.
- **Balance.** Storage and ship-out payments are taken from the customer's account balance. For a test, ask the business to credit the test customer's balance.
- **Test data.** A warehouse `id`, at least one packaging `id` if custom packages are not allowed, and at least one active shipping service available from that warehouse. Ship-out works only after the warehouse has **received** the stored packages; in a test, ask the warehouse staff to receive the test storage order.
- **Token handling.** Sign in from your server, keep the token on the server, and never place it in browser or mobile code.
- **Placeholders.** Replace `YOUR_HOST` with your platform host and `ACCESS_TOKEN` with the token from step 4.

## 4. Sign in as the customer

Every later call is authorised with a customer bearer token. Your integration signs in once, stores the token server side and renews it before `expires_at`.

**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" \
  -d '{"email":"ops@northwind-outdoor.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2027-09-28 10:15:00",
  "expires_timestamp": 1822040100,
  "name": "Northwind Outdoor"
}
```

- `access_token` — send it on every request as the header below.
- `expires_at` / `expires_timestamp` — sign in again before this time.

```
Authorization: Bearer ACCESS_TOKEN
```

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

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

## 5. Read the storage configuration

The configuration bundle lists the warehouses the customer may use, the packaging catalog, the units and the surcharges. Your integration reads it once per session to choose the warehouse and to build valid package lines.

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

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

```json
{
  "result": true,
  "data": {
    "warehouses": [
      { "id": 7, "name": "Toronto Hub", "address": "10 Main St", "city": "Toronto", "province": "ON", "postcode": "M5V 2T6" }
    ],
    "packagings": [
      { "id": 1, "name": "Large Carton", "type": "Box", "length": 60, "width": 40, "height": 40, "dimension_unit": 2 }
    ],
    "dimension_units": { "1": { "name": "dimension_mm" }, "2": { "name": "dimension_cm" }, "3": { "name": "dimension_m" }, "4": { "name": "dimension_inch" } },
    "weight_units": { "1": { "name": "weight_g" }, "2": { "name": "weight_kg" }, "3": { "name": "weight_oz" }, "4": { "name": "weight_lb" } },
    "allow_custom_package": true,
    "surcharges": [],
    "form_bindings": []
  }
}
```

- `warehouses[].id` — the `warehouse_id` for every later call.
- `allow_custom_package` — when `false`, every storage item must carry a `packaging_id` from `packagings[]`; when `true`, items may be described by dimensions only.
- `dimension_units` / `weight_units` — the integer codes used in package lines (`2` = cm, `2` = kg).
- `form_bindings` — forms the business requires on a storage order; send their answers as `form_data` in step 7.

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

```graphql
query {
  customerStorageOrderConfig
}
```

**Verification:** you captured a warehouse `id` and, if the catalog is not empty, a packaging `id`.

## 6. Quote the storage period

The quote prices the storage period for the planned packages before anything is booked. Your integration shows or checks this price, then creates the order with the same inputs.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/calculate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "items": [{
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2
    }]
  }'
```

```json
{
  "success": true,
  "price": {
    "total_price": "186.45",
    "currency": "CAD",
    "shipping_price": 186.45
  }
}
```

- `success` — `true` when a price was calculated.
- `price.total_price` / `price.currency` — the storage price including tax for the period.
- `promotion` — present only when a promotion applies.

**Verification:** `success` or `result` is true and you have a price. Missing `warehouse_id` / dates is `400`.

## 7. Create the storage order

The storage order announces the inbound packages to the warehouse and fixes the storage period. Your integration stores the returned id; it is needed to pay, to read the order and to cancel it.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-winter-2026-po-4471" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "notes": "Winter 2026 stock, PO 4471",
    "items": [{
      "description": "Insulated jackets, carton of 12",
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2,
      "value": 1800
    }]
  }'
```

```json
{
  "result": true,
  "message": "Storage order created",
  "data": { "id": 1024, "status": "pending payment" }
}
```

- `data.id` — the storage order id. Store it with your purchase order.
- `data.status` — `pending payment` until the order is paid.
- `Idempotency-Key` — derive it from your own stable id. A repeated key with the same body replays the first answer (`replayed: true`); the same key with a different body is refused with `409 IDEMPOTENCY_CONFLICT`.
- Required fields: `warehouse_id`, `start_date`, `end_date` (after `start_date`), and `items[]` with `qty`, `length`, `width`, `height`, `dimension_unit`. Add `items[].packaging_id` when `allow_custom_package` is `false`.

**Verification:** response has `data.id`. Store that storage order id.

## 8. Pay for storage

Payment confirms the storage order. Your integration can read the amount due first, then pays from the customer's balance.

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

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/1024/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "success": true,
  "result": true,
  "data": {
    "order_id": 1024,
    "currency": "CAD",
    "total_price": "186.45",
    "paid_amount": "0.00",
    "remaining_balance": "186.45",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "minimum_payment": "186.45"
  }
}
```

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

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

```json
{
  "success": true,
  "result": true,
  "message": "Payment of $186.45 processed successfully. Your storage order is now confirmed.",
  "new_balance": 313.55,
  "paid_amount": 186.45,
  "charge_amount": 186.45,
  "is_fully_paid": true
}
```

- `payment_type` — `full_balance` (default, pays the remaining balance), `minimum_payment` (pays the minimum the business requires), or `custom` together with `custom_amount`.
- `is_fully_paid` — `true` when nothing remains to pay.
- An insufficient balance answers `400` with `customer_balance`; top up the balance, then retry.

Read the order to confirm its status and, later, which packages the warehouse has received.

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

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

```json
{
  "result": true,
  "data": {
    "id": 1024,
    "status": "confirmed",
    "can_cancel": true,
    "store_from": "2026-11-01",
    "store_to": "2027-03-31",
    "warehouse_id": 7,
    "packages": [
      { "id": 5001, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false },
      { "id": 5002, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false }
    ],
    "total_price": 186.45,
    "currency": "CAD"
  }
}
```

- `status` — `confirmed` after payment; later `partial received` / `storage in progress` as goods arrive.
- `packages[].received` — `true` once the warehouse has received that package.
- `can_cancel` — whether the storage order may still be cancelled.

**GraphQL:** `customerStorageOrderShow` ([GraphQL handbook](/api/graphql/documentation#/customer/customerStorageOrderShow)); the list of all storage orders is `customerStorageOrders` ([GraphQL handbook](/api/graphql/documentation#/customer/customerStorageOrders)).

```graphql
query {
  customerStorageOrderShow(id: 1024) {
    result
    data {
      id
      status
      can_cancel
      packages { id description received }
    }
  }
}
```

**Verification:** the storage order is paid / confirmed. A `400` with `customer_balance` means top up the balance, then retry.

Ship-out below only works after packages are **received** in the warehouse. For a test, wait until staff (or a test receive) has marked them received, then continue.

## 9. List items still in stock

This list is the stock your integration may ship. It contains only packages the warehouse has received and that are not already locked to another ship-out.

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

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/available-items?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "warehouses": [{ "id": 7, "name": "Toronto Hub", "available_count": 2 }],
    "storage_orders": [{
      "id": 1024,
      "warehouse_id": 7,
      "packages": [
        { "id": 5001, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 },
        { "id": 5002, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 }
      ]
    }]
  }
}
```

- `storage_orders[].packages[].id` — the `storage_package_ids` to ship out in step 10.
- `warehouses[].available_count` — the number of packages available per warehouse.

**GraphQL:** `customerShipoutAvailableItems` ([GraphQL handbook](/api/graphql/documentation#/storage-shipout/customerShipoutAvailableItems))

```graphql
query {
  customerShipoutAvailableItems(warehouse_id: 7) {
    result
    data {
      warehouses { id name available_count }
      storage_orders { id warehouse_id packages { id description weight length width height } }
    }
  }
}
```

**Verification:** you captured one or more `storage_package_ids` (example `5001`). Empty list means nothing is received yet — do not create a ship-out. `403` means ship-out is disabled for this customer.

## 10. Estimate and create the ship-out

A ship-out is priced by a shipping service of the business. Your integration lists the services available from the warehouse, estimates the price for the buyer's destination, then creates the ship-out for the selected packages.

**REST:** `GET /api/v1/customer/shipout-orders/services?warehouse_id=7` — [REST handbook](/api/documentation#/paths/v1-customer-shipout-orders-services/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/services?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "services": [
      { "id": 5, "service_code": "intl_express", "name": { "en": "Express" }, "pricing_method": 1, "pricing_method_name": "Shipping Price Plan", "support_multi_package": true }
    ]
  }
}
```

Capture a `service_code`.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/estimate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "delivery_postcode": "K2P1L4",
    "delivery_country": "CA",
    "packages": [{
      "weight": 14,
      "length": 60,
      "width": 40,
      "height": 40,
      "weight_unit": 2,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "shipping_fee": 38.5,
    "fuel_surcharge": 4.2,
    "handling_fee": 0,
    "sub_total": 42.7,
    "tax": 5.55,
    "total": 48.25,
    "currency": "CAD",
    "has_items_needing_quote": false,
    "refused": false
  }
}
```

- `total` / `currency` — the estimated price for this destination.
- `has_items_needing_quote` — `true` when the service is priced manually; the warehouse sets the price after the ship-out is created, and payment waits for it.
- `refused` / `refusal_message` — the service will not accept this shipment because it cannot price it.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-order-NW-20931" \
  -d '{
    "warehouse_id": 7,
    "storage_package_ids": [5001],
    "delivery_name": "Maya Chen",
    "delivery_telephone": "6135550142",
    "delivery_email": "maya.chen@example.com",
    "delivery_address_1": "150 Elgin St",
    "delivery_city": "Ottawa",
    "delivery_province": "ON",
    "delivery_country": "CA",
    "delivery_postcode": "K2P1L4",
    "note": "Web order NW-20931"
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 0,
    "is_storage_shipout": true,
    "total_price": "48.25",
    "price_breakdown": { "total": 48.25, "has_items_needing_quote": false },
    "has_items_needing_quote": false,
    "storage_package_ids": [5001]
  }
}
```

- `data.id` — the ship-out id. Store it with the buyer's order.
- `data.status` — `0` = pending (awaiting payment), `1` = confirmed, `2` = in transit, `3` = shipped, `4` = cancelled, `5` = failed.
- `storage_package_ids` — these packages are now locked to this ship-out and no longer appear in step 9.
- Required fields: `warehouse_id`, `storage_package_ids`, `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`. All packages must come from the same warehouse.

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

```graphql
mutation {
  customerCreateShipoutOrder(
    service_code: "intl_express"
    warehouse_id: 7
    storage_package_ids: [5001]
    delivery_name: "Maya Chen"
    delivery_telephone: "6135550142"
    delivery_email: "maya.chen@example.com"
    delivery_address_1: "150 Elgin St"
    delivery_city: "Ottawa"
    delivery_province: "ON"
    delivery_country: "CA"
    delivery_postcode: "K2P1L4"
    note: "Web order NW-20931"
  ) {
    result
    message
    data { id status total_price has_items_needing_quote storage_package_ids }
  }
}
```

**Verification:** response has a ship-out `id`. Selected storage packages are locked to this request.

## 11. Pay the ship-out

The warehouse processes a ship-out once it is paid. Your integration pays the remaining balance from the customer's account; omit `amount` to pay it in full.

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

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

```json
{
  "result": true,
  "data": {
    "transaction_id": 9001,
    "amount": "48.25",
    "order_status": 1,
    "paid_amount": 48.25,
    "remaining_balance": 0
  }
}
```

- `amount` (request, optional) — a partial amount; defaults to the full remaining balance.
- `order_status` — `1` (confirmed) after full payment.
- `remaining_balance` — `0` when fully paid.

**GraphQL:** `customerPayShipout` ([GraphQL handbook](/api/graphql/documentation#/storage-shipout/customerPayShipout))

```graphql
mutation {
  customerPayShipout(id: 8001) {
    result
    message
    data { transaction_id amount order_status paid_amount remaining_balance }
  }
}
```

**Verification:** pay records an amount (or `402` / `422` with a clear reason). `402` means the balance is insufficient; `422` means the order is not payable yet (for example, it still waits for a manual quote) or the amount is invalid.

## 12. Read and track the ship-out

Your integration reads the ship-out to follow its status and, once the warehouse has shipped it, follows the shipment by its tracking number.

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

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

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 3,
    "status_label": "Shipped",
    "warehouse": { "id": 7, "name": "Toronto Hub" },
    "shipping_service": { "id": 5, "service_code": "intl_express" },
    "total_price": "48.25",
    "paid_amount": 48.25,
    "remaining_balance": 0,
    "can_be_paid": false,
    "can_be_cancelled": false,
    "storage_packages": [{ "id": 5001, "storage_order_id": 1024, "description": "Insulated jackets, carton of 12" }]
  }
}
```

- `status` / `status_label` — the current ship-out state.
- `can_be_paid` / `can_be_cancelled` — whether step 11 or step 14 is currently allowed.

When a tracking number exists:

**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,
  "deliveried": false,
  "data": [
    { "tracking_event_status_id": 3, "description": "Package picked up", "location_city": "Toronto", "updated_at": "2026-12-02 14:30:00" }
  ]
}
```

- `data[]` — tracking events in chronological order.
- `deliveried` — `true` once the shipment is delivered.

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

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

**Verification:** the ship-out read returns the expected `status`. Public tracking finds the shipment once a number exists.

## 13. Subscribe to webhooks

Webhooks push tracking and status changes to your server instead of polling. A customer account sets its own webhook URLs and signing secret; the settings are stored on the customer account, not on the business.

**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 '{
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status",
    "webhook_sign_secret": "nw-webhook-secret-2026-0123456789"
  }'
```

```json
{
  "result": true,
  "changed_keys": ["tracking_event_webhook_url", "order_status_change_webhook_url", "webhook_sign_secret"],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "************6789",
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status"
  }
}
```

- Only the submitted keys change; an unknown key or invalid URL answers `400`.
- `recipient_type` — `customer` confirms the settings belong to the customer account.
- `webhook_sign_secret` — 16 to 255 characters; store it on your server to verify signatures.

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

Verify **v2**: `HMAC_SHA256(timestamp + "." + raw_body, secret)` against `X-Webhook-Signature-V2`. Deduplicate on `X-Webhook-Event-Id`. Answer **2xx in under 3 seconds**.

```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:** the update returns `changed_keys` with the submitted keys, and a test event received at your URL passes the signature check above.

## 14. Cancel a ship-out or a storage order

Cancellation releases what was reserved. Cancelling a ship-out returns its packages to stock; cancelling a storage order stops a booking whose goods have not yet been received.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/8001/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Buyer cancelled web order NW-20931"}'
```

```json
{ "result": true, "message": "Shipout cancelled." }
```

- `reason` (optional) — recorded with the cancellation.
- A ship-out can be cancelled only while it is pending (`0`) or confirmed (`1`).

**GraphQL:** `customerCancelShipout` ([GraphQL handbook](/api/graphql/documentation#/storage-shipout/customerCancelShipout))

```graphql
mutation {
  customerCancelShipout(id: 8001, reason: "Buyer cancelled web order NW-20931") {
    result
    message
  }
}
```

This releases the storage-package lock. Storage itself is cancelled with `POST /api/v1/customer/storage-orders/{id}/cancel` while it is still allowed (status `pending payment`, `confirmed`, `waiting for pickup` or `awaiting dropoff`; [REST handbook](/api/documentation#/paths/v1-customer-storage-orders-id--cancel/post)).

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

```json
{ "result": true, "message": "Storage order cancelled", "data": { "id": 1024, "status": "cancelled" } }
```

- The amount already paid for the storage order is credited back to the customer's balance.
- A storage order in any other status answers `403`.

**Verification:** `422` means this status cannot be cancelled. After a successful ship-out cancel, step 9 lists the packages again.

## 15. Handling errors

| Situation | HTTP status | Code | What the integration does |
|---|---|---|---|
| Missing or expired token, or wrong account type | 401 | — | Sign in again as the customer (step 4). |
| Storage quote without `warehouse_id` or dates | 400 | — | Send `warehouse_id`, `start_date` and `end_date`. |
| Storage order validation failed (missing item dimensions, `end_date` not after `start_date`, missing `packaging_id`) | 422 | — | Read `errors`, correct the fields and resend. |
| Storage payment with insufficient balance, or order already fully paid | 400 | — | Top up the balance (the answer carries `customer_balance`), or stop if already paid. |
| Storage payment with `custom_amount` outside the allowed range | 422 | — | Pay an amount between the minimum and the remaining balance. |
| Storage order cannot be cancelled in its current status | 403 | — | Ask the warehouse to handle the order; do not retry. |
| Ship-out disabled for this customer | 403 | — | Ask the business to enable ship-out for the customer account. |
| Service code unknown, or ship-out / storage order not found | 404 | — | Re-read the services list or check the stored id. |
| Package not available, packages from different warehouses, or service not offered from the warehouse | 422 | — | Re-read step 9 and select available packages from one warehouse. |
| Shipping service cannot price the shipment and refuses it | 422 | `unpriced_refused` | Choose another service or destination; nothing was created. |
| Ship-out payment with insufficient balance | 402 | — | Top up the balance, then retry step 11. |
| Ship-out not payable yet (waiting for a manual quote) or invalid amount | 422 | — | Wait for the price, read the ship-out again, then pay. |
| Ship-out cannot be cancelled in its current status | 422 | — | The shipment is already in progress; do not retry. |
| Same `Idempotency-Key` sent with a different body | 409 | `IDEMPOTENCY_CONFLICT` | Use a new key for a different request. |
| Original request with the same `Idempotency-Key` still processing | 409 | `IDEMPOTENCY_IN_PROGRESS` | Wait for `Retry-After` seconds and resend the same request. |

## Test checklist

- [ ] Storage config returns a warehouse `id`.
- [ ] Storage quote returns a price, and storage create returns `data.id`.
- [ ] Storage pay succeeds, **or** you confirmed the wallet must be topped up.
- [ ] Available-items lists received packages (`storage_package_ids`).
- [ ] Ship-out estimate returns a price or `has_items_needing_quote`, and ship-out create returns an `id` and locks those packages.
- [ ] Ship-out pay succeeds (or `402` / `422` is understood).
- [ ] Public tracking finds the shipment once a tracking number exists.
- [ ] Ship-out cancel releases the packages, **or** this status cannot be cancelled.
- [ ] Repeating a create with the same `Idempotency-Key` and body returns `replayed: true` and no second order.
- [ ] Webhook settings return `recipient_type: customer`, and a received event passes the v2 signature check.
