# 倉儲與出庫

倉儲與出庫 API 讓倉儲商戶的客戶帳號將貨物入庫存放、支付存放期費用，之後將在庫包裹出庫寄給自己的買家。本專題面向將庫存存放在第三方倉庫（3PL）、需要從自有系統自動完成倉儲預訂、庫存檢視和出庫出貨的商家和平台。所有呼叫均以客戶帳號的身分執行，而不是以倉儲商戶的身分。

## 1. 可以建構的應用

本專題中的範例都遵循同一個情境。**Northwind Outdoor** 是一家季節性線上銷售冬季裝備的商家，於 2026 年 11 月 1 日至 2027 年 3 月 31 日將其冬季庫存存放在其 3PL 的 **Toronto Hub**（倉庫 `7`）。買家訂購一箱保暖夾克時，Northwind 從庫存中將該箱貨物寄往位於渥太華的買家。

- **季節性倉儲預訂。** 商家的後台在貨物離開供應商之前，為每個入庫箱詢價並預訂存放期，並從帳戶餘額中支付倉儲費。
- **即時庫存檢視。** 商家的商店或 ERP 列出倉庫實際已收到且仍可出貨的包裹，確保只將真實庫存用於履約。
- **從庫存履約訂單。** 買家下單時，商家的系統為出庫貨件估價，為在庫包裹建立出庫請求並付款，然後為買家記錄運單號。
- **狀態追蹤與更正。** 商家的系統讀取每張倉儲訂單和出庫單的狀態，透過公開追蹤追蹤貨件，並在仍允許時取消不再需要的出庫單。

## 2. 本專題的適用範圍

當貨物已經或將要存放在商戶的倉庫中，且貨件從該庫存寄出時，使用本專題。流程為：登入 → 讀取倉儲設定 → 倉儲詢價 → 建立倉儲訂單 → 付款 → 列出在庫包裹 → 列出服務並估算出庫價格 → 建立出庫單 → 付款 → 查詢與追蹤 → Webhook → 取消。

其他專題適用於其他情況：

- **Uniorder：所有貨件統一一個介面** —— 預訂本地配送或承運商面單的新整合建議使用的統一入口（`/api/v1/uniorder/...`）。Uniorder **不**涵蓋倉儲與出庫；倉儲訂單和出庫單只能透過本專題中的客戶介面建立。
- **運送服務** —— 客戶使用商戶的運送服務寄送不在倉儲中的貨物。
- **承運商打單** —— 商戶直接為自己的包裹購買承運商面單。
- **自有車隊取件與配送** —— 商戶使用自有車隊預訂取件和配送。

## 3. 開始之前

- **帳號類型。** 倉儲商戶的**客戶**帳號（營運倉庫的商戶為服務提供方）。商戶（客戶）帳號的權杖不能用於 `/api/v1/customer/...` 介面。
- **權限。** 客戶帳號需要 API 存取權限。倉儲介面還需要倉儲功能；出庫介面要求商戶已為該客戶啟用出庫（或合併出貨），否則回傳 `403`。
- **餘額。** 倉儲和出庫費用從客戶的帳戶餘額中扣除。測試時，請商戶為測試客戶的餘額儲值。
- **測試資料。** 一個倉庫 `id`；若不允許自訂包裹，至少一個包裝 `id`；以及至少一項可從該倉庫使用的有效運送服務。出庫只有在倉庫**已收到**在庫包裹之後才能進行；測試時，請倉庫人員對測試倉儲訂單執行收貨。
- **權杖處理。** 從您的伺服器登入，將權杖保存在伺服器上，切勿將其放入瀏覽器或行動端程式碼中。
- **預留位置。** 將 `YOUR_HOST` 替換為您的平台主機，將 `ACCESS_TOKEN` 替換為第 4 步取得的權杖。

## 4. 以客戶身分登入

之後的每個呼叫都使用客戶的 Bearer 權杖授權。您的整合登入一次，將權杖保存在伺服器端，並在 `expires_at` 之前更新。

**REST：** `POST /api/v1/user/customer/login` — [REST 手冊](/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` —— 在每個請求中依下方請求標頭傳送。
- `expires_at` / `expires_timestamp` —— 在此時間之前重新登入。

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL 請求提交至 `POST /api/graphql`，並使用相同的請求標頭。

**驗證：** 登入成功後回傳 `access_token`。後續請求若未攜帶該權杖，將回傳 `401`。

## 5. 讀取倉儲設定

設定包列出客戶可使用的倉庫、包裝目錄、單位和附加費。您的整合在每個工作階段中讀取一次，用於選擇倉庫並建構有效的包裹行。

**REST：** `GET /api/v1/customer/storage-orders/config` — [REST 手冊](/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` —— 之後每個呼叫中的 `warehouse_id`。
- `allow_custom_package` —— 為 `false` 時，每個倉儲項都必須帶有 `packagings[]` 中的 `packaging_id`；為 `true` 時，倉儲項可以只用尺寸描述。
- `dimension_units` / `weight_units` —— 包裹行中使用的整數代碼（`2` = 公分，`2` = 公斤）。
- `form_bindings` —— 商戶要求在倉儲訂單上填寫的表單；請在第 7 步以 `form_data` 傳送其答案。

**GraphQL：** `customerStorageOrderConfig`（[GraphQL 手冊](/api/graphql/documentation#/customer/customerStorageOrderConfig)）

```graphql
query {
  customerStorageOrderConfig
}
```

**驗證：** 您已記錄一個倉庫 `id`；若目錄不為空，還記錄了一個包裝 `id`。

## 6. 存放期詢價

詢價在預訂任何內容之前，為計畫中的包裹計算存放期價格。您的整合顯示或核對該價格，然後使用相同的輸入建立訂單。

**REST：** `POST /api/v1/customer/storage-orders/calculate-price` — [REST 手冊](/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`。
- `price.total_price` / `price.currency` —— 該存放期的含稅倉儲價格。
- `promotion` —— 僅在適用促銷時出現。

**驗證：** `success` 或 `result` 為 true，且您取得了價格。缺少 `warehouse_id` 或日期時回傳 `400`。

## 7. 建立倉儲訂單

倉儲訂單向倉庫預告入庫包裹，並確定存放期。您的整合保存回傳的 id；付款、查詢和取消訂單時都需要它。

**REST：** `POST /api/v1/customer/storage-orders` — [REST 手冊](/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` —— 倉儲訂單 id。請與您的採購訂單一起保存。
- `data.status` —— 訂單付款前為 `pending payment`。
- `Idempotency-Key` —— 由您自己的固定 id 產生。以相同的請求本文重複使用同一個鍵時，回傳第一次的回應（`replayed: true`）；以不同的請求本文使用同一個鍵時，將以 `409 IDEMPOTENCY_CONFLICT` 拒絕。
- 必填欄位：`warehouse_id`、`start_date`、`end_date`（晚於 `start_date`），以及帶有 `qty`、`length`、`width`、`height`、`dimension_unit` 的 `items[]`。`allow_custom_package` 為 `false` 時，還需加入 `items[].packaging_id`。

**驗證：** 回應帶有 `data.id`。請保存該倉儲訂單 id。

## 8. 支付倉儲費用

付款即確認倉儲訂單。您的整合可以先讀取應付金額，然後從客戶餘額中支付。

**REST：** `GET /api/v1/customer/storage-orders/{id}/payment-info` — [REST 手冊](/api/documentation#/paths/v1-customer-storage-orders-id--payment-info/get)（可選）

```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 手冊](/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`（預設，支付剩餘全部金額）、`minimum_payment`（支付商戶要求的最低金額），或 `custom` 並搭配 `custom_amount`。
- `is_fully_paid` —— 已無剩餘應付金額時為 `true`。
- 餘額不足時回傳 `400` 並帶有 `customer_balance`；請為餘額儲值後重試。

查詢訂單，以確認其狀態，以及之後倉庫已收到哪些包裹。

**REST：** `GET /api/v1/customer/storage-orders/{id}` — [REST 手冊](/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`；貨物到達後依次變為 `partial received` / `storage in progress`。
- `packages[].received` —— 倉庫收到該包裹後為 `true`。
- `can_cancel` —— 倉儲訂單是否仍可取消。

**GraphQL：** `customerStorageOrderShow`（[GraphQL 手冊](/api/graphql/documentation#/customer/customerStorageOrderShow)）；所有倉儲訂單的列表為 `customerStorageOrders`（[GraphQL 手冊](/api/graphql/documentation#/customer/customerStorageOrders)）。

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

**驗證：** 倉儲訂單已付款 / 已確認。回傳 `400` 並帶有 `customer_balance` 表示需要為餘額儲值後重試。

以下出庫操作只有在包裹於倉庫中**已收貨**後才能進行。測試時，請等待倉庫人員（或測試收貨操作）將其標記為已收貨，然後繼續。

## 9. 列出仍在庫的貨物

此列表即您的整合可以出貨的庫存。它只包含倉庫已收到、且尚未鎖定到其他出庫單的包裹。

**REST：** `GET /api/v1/customer/shipout-orders/available-items` — [REST 手冊](/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` —— 第 10 步中要出庫的 `storage_package_ids`。
- `warehouses[].available_count` —— 每個倉庫的可用包裹數量。

**GraphQL：** `customerShipoutAvailableItems`（[GraphQL 手冊](/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 } }
    }
  }
}
```

**驗證：** 您已記錄一個或多個 `storage_package_ids`（例如 `5001`）。列表為空表示尚未收到任何貨物——請勿建立出庫單。`403` 表示該客戶的出庫功能已停用。

## 10. 估價並建立出庫單

出庫單由商戶的某項運送服務定價。您的整合列出可從該倉庫使用的服務，為買家的目的地估價，然後為所選包裹建立出庫單。

**REST：** `GET /api/v1/customer/shipout-orders/services?warehouse_id=7` — [REST 手冊](/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 }
    ]
  }
}
```

記錄一個 `service_code`。

**REST：** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/estimate` — [REST 手冊](/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` —— 該目的地的估算價格。
- `has_items_needing_quote` —— 服務由人工定價時為 `true`；倉庫在出庫單建立後設定價格，付款須等待定價。
- `refused` / `refusal_message` —— 服務因無法定價而不接受此貨件。

**REST：** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/orders` — [REST 手冊](/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` —— 出庫單 id。請與買家的訂單一起保存。
- `data.status` —— `0` = 待處理（等待付款），`1` = 已確認，`2` = 運送中，`3` = 已出貨，`4` = 已取消，`5` = 失敗。
- `storage_package_ids` —— 這些包裹現已鎖定到本出庫單，不再出現在第 9 步中。
- 必填欄位：`warehouse_id`、`storage_package_ids`、`delivery_name`、`delivery_telephone`、`delivery_address_1`、`delivery_city`、`delivery_province`、`delivery_country`、`delivery_postcode`。所有包裹必須來自同一倉庫。

**GraphQL：** `customerCreateShipoutOrder`（[GraphQL 手冊](/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 }
  }
}
```

**驗證：** 回應帶有出庫單 `id`。所選的倉儲包裹已鎖定到該請求。

## 11. 支付出庫單

出庫單付款後，倉庫才會處理。您的整合從客戶帳戶中支付剩餘金額；省略 `amount` 即全額支付。

**REST：** `POST /api/v1/customer/shipout-orders/{id}/pay` — [REST 手冊](/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`（請求參數，可選）—— 部分金額；預設為剩餘全部金額。
- `order_status` —— 全額付款後為 `1`（已確認）。
- `remaining_balance` —— 全額付款後為 `0`。

**GraphQL：** `customerPayShipout`（[GraphQL 手冊](/api/graphql/documentation#/storage-shipout/customerPayShipout)）

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

**驗證：** 付款記錄了金額（或回傳帶有明確原因的 `402` / `422`）。`402` 表示餘額不足；`422` 表示訂單尚不可付款（例如仍在等待人工報價）或金額無效。

## 12. 查詢和追蹤出庫單

您的整合查詢出庫單以追蹤其狀態，倉庫出貨後再透過運單號追蹤貨件。

**REST：** `GET /api/v1/customer/shipout-orders/{id}` — [REST 手冊](/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` —— 出庫單的目前狀態。
- `can_be_paid` / `can_be_cancelled` —— 目前是否允許執行第 11 步或第 14 步。

已有運單號時：

**REST：** `GET /api/v1/tracking/{trackingNumber}` — [REST 手冊](/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[]` —— 依時間順序排列的追蹤事件。
- `deliveried` —— 貨件送達後為 `true`。

**GraphQL：** `trackingPublic`（[GraphQL 手冊](/api/graphql/documentation#/tracking/trackingPublic)）

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

**驗證：** 查詢出庫單回傳預期的 `status`。已有單號後，公開追蹤可以查到該貨件。

## 13. 訂閱 Webhook

Webhook 將追蹤和狀態變更推送到您的伺服器，無需輪詢。客戶帳號自行設定其 Webhook URL 和簽名密鑰；這些設定保存在客戶帳號上，而不是商戶上。

**REST：** `PUT /api/v1/webhook-settings` — [REST 手冊](/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"
  }
}
```

- 只變更提交的鍵；未知的鍵或無效的 URL 回傳 `400`。
- `recipient_type` —— `customer` 表示這些設定屬於客戶帳號。
- `webhook_sign_secret` —— 16 至 255 個字元；請保存在您的伺服器上用於驗證簽名。

**GraphQL：** `webhookSettingsUpdate`（[GraphQL 手冊](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)）。

驗證 **v2** 簽名：計算 `HMAC_SHA256(timestamp + "." + raw_body, secret)` 並與 `X-Webhook-Signature-V2` 比對。依 `X-Webhook-Event-Id` 去重。請在 **3 秒內回傳 2xx**。

```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;
}
```

**驗證：** 更新呼叫回傳包含所提交鍵的 `changed_keys`，且您的 URL 收到的測試事件通過上述簽名驗證。

## 14. 取消出庫單或倉儲訂單

取消會釋放已預留的內容。取消出庫單會將其包裹退回庫存；取消倉儲訂單會終止貨物尚未收到的預訂。

**REST：** `POST /api/v1/customer/shipout-orders/{id}/cancel` — [REST 手冊](/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`（可選）—— 隨取消一併記錄。
- 出庫單只有在待處理（`0`）或已確認（`1`）狀態時才能取消。

**GraphQL：** `customerCancelShipout`（[GraphQL 手冊](/api/graphql/documentation#/storage-shipout/customerCancelShipout)）

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

這會解除倉儲包裹的鎖定。倉儲本身在仍允許時透過 `POST /api/v1/customer/storage-orders/{id}/cancel` 取消（狀態為 `pending payment`、`confirmed`、`waiting for pickup` 或 `awaiting dropoff`；[REST 手冊](/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" } }
```

- 倉儲訂單已支付的金額將退回客戶餘額。
- 處於其他任何狀態的倉儲訂單回傳 `403`。

**驗證：** `422` 表示該狀態不能取消。成功取消出庫單後，第 9 步會再次列出這些包裹。

## 15. 錯誤處理

| 情形 | HTTP 狀態 | 代碼 | 整合方的處理 |
|---|---|---|---|
| 權杖缺失或已過期，或帳號類型錯誤 | 401 | — | 以客戶身分重新登入（第 4 步）。 |
| 倉儲詢價缺少 `warehouse_id` 或日期 | 400 | — | 傳送 `warehouse_id`、`start_date` 和 `end_date`。 |
| 倉儲訂單驗證失敗（缺少倉儲項尺寸、`end_date` 不晚於 `start_date`、缺少 `packaging_id`） | 422 | — | 讀取 `errors`，更正欄位後重新傳送。 |
| 餘額不足時支付倉儲費用，或訂單已全額支付 | 400 | — | 為餘額儲值（回應帶有 `customer_balance`），若已付款則停止。 |
| 倉儲付款的 `custom_amount` 超出允許範圍 | 422 | — | 支付介於最低金額與剩餘金額之間的金額。 |
| 倉儲訂單在目前狀態下無法取消 | 403 | — | 請倉庫處理該訂單；不要重試。 |
| 該客戶的出庫功能已停用 | 403 | — | 請商戶為該客戶帳號啟用出庫。 |
| 服務代碼未知，或找不到出庫單 / 倉儲訂單 | 404 | — | 重新讀取服務列表或檢查已保存的 id。 |
| 包裹不可用、包裹來自不同倉庫，或該倉庫不提供該服務 | 422 | — | 重新讀取第 9 步，並從同一倉庫選擇可用包裹。 |
| 運送服務無法為貨件定價並拒絕該貨件 | 422 | `unpriced_refused` | 選擇其他服務或目的地；未建立任何內容。 |
| 餘額不足時支付出庫單 | 402 | — | 為餘額儲值後重試第 11 步。 |
| 出庫單尚不可付款（等待人工報價）或金額無效 | 422 | — | 等待定價，重新查詢出庫單後再付款。 |
| 出庫單在目前狀態下無法取消 | 422 | — | 貨件已在處理中；不要重試。 |
| 相同的 `Idempotency-Key` 與不同的請求本文一起傳送 | 409 | `IDEMPOTENCY_CONFLICT` | 不同的請求使用新的鍵。 |
| 使用相同 `Idempotency-Key` 的原始請求仍在處理中 | 409 | `IDEMPOTENCY_IN_PROGRESS` | 等待 `Retry-After` 秒後重新傳送相同的請求。 |

## 驗收清單

- [ ] 倉儲設定回傳倉庫 `id`。
- [ ] 倉儲詢價回傳價格，建立倉儲訂單回傳 `data.id`。
- [ ] 倉儲費用付款成功，**或**已確認錢包需要儲值。
- [ ] 可用貨物列表列出已收貨的包裹（`storage_package_ids`）。
- [ ] 出庫估價回傳價格或 `has_items_needing_quote`，建立出庫單回傳 `id` 並鎖定這些包裹。
- [ ] 出庫單付款成功（或已理解 `402` / `422` 的含義）。
- [ ] 已有運單號後，公開追蹤可以查到該貨件。
- [ ] 取消出庫單會釋放包裹，**或**該狀態不能取消。
- [ ] 使用相同的 `Idempotency-Key` 和請求本文重複建立時，回傳 `replayed: true` 且不會建立第二張訂單。
- [ ] Webhook 設定回傳 `recipient_type: customer`，且收到的事件通過 v2 簽名驗證。
