# 運送服務

運送服務 API 讓客戶帳號預訂其物流服務商已設定並分配給它的運送服務。客戶自己的系統列出可使用的服務、載入某項服務的規則、對貨件估價、建立運送訂單、從帳戶餘額中付款，並追蹤貨件直至送達。本專題面向將進口商、商家或批發商的系統與為其服務的物流服務商串接的開發人員。

## 1. 可以建構的應用

本專題中的所有範例都使用同一個情境。**Harbourline Imports Inc.** 是多倫多的一家茶葉進口商，在其物流服務商處持有客戶帳號。該服務商從其 Toronto Hub 倉庫（倉庫 id `7`）提供服務 `intl_express`（International Express）。Harbourline 將兩箱茶葉樣品送到 Toronto Hub，寄給西雅圖的一家經銷商，採購訂單號為 `HLI-PO-1058`。

- **從採購訂單系統預訂。** 採購訂單下達後，Harbourline 的系統依 `intl_express` 對貨件估價，以採購訂單號作為參考號建立運送訂單，並從預付帳戶餘額中付款，無需任何人開啟服務商的入口網站。
- **確認前的價格核對。** Harbourline 的採購人員在貨件預訂前即可看到兩箱貨物的運費、附加費、稅費和總額；服務無法定價的貨件會在訂單建立前被攔下。
- **ERP 內的貨件狀態。** 每箱貨物的運單號與採購訂單關聯保存；Webhook 將訂單狀態和追蹤時間軸同步到 ERP，夜間任務再與訂單列表對帳。
- **受控的變更。** 未付款的預訂可直接更正；不再需要的預訂可以取消，已付金額退回帳戶額度。

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

當呼叫方是物流商戶的**客戶**，並預訂該商戶自有的運送服務時，使用本類介面：商戶設定價格方案、倉庫、附加費和包裝，並將服務分配給客戶。客戶只能看到並預訂分配給它的服務。

以下情況請使用其他類介面：

- 呼叫方為物流商戶本身（商戶/客戶帳號），使用自有車隊預訂當日或本地的取件與配送：請閱讀 **自有車隊取件與配送**。
- 呼叫方依帳號的協議價購買承運商面單（例如 UPS 或 FedEx）：請閱讀 **承運商打單**。
- 客戶將貨物存放在服務商的倉庫，並從庫存出庫出貨：請閱讀 **倉儲與出庫**。

**Uniorder：所有貨件統一一個介面**（`/api/v1/uniorder/...`）是本地配送和承運商面單新整合的建議統一入口。Uniorder 不涵蓋運送服務：運送服務訂單只能透過本專題介紹的 `/api/v1/customer/shipping-orders/...` 介面建立和管理。

## 3. 開始之前

- **帳號類型。** 物流商戶的**客戶**帳號，並由商戶開通 **API 權限**。商戶/客戶帳號或員工帳號無法透過下面的客戶登入介面登入。
- **服務分配。** 商戶必須至少為客戶分配一項有效的運送服務。未分配任何服務的客戶將收到空的服務列表。
- **測試資料。** 與商戶商定一個測試服務代碼、一個測試倉庫，並在測試帳號中預存少量餘額。使用 `HLI-PO-1058` 或 `DEV-SHIP-001` 等參考號，以便查找和取消測試訂單。
- **權杖處理。** 僅從您的伺服器呼叫 API。不要將密碼和存取權杖放在瀏覽器或行動用戶端中。權杖在登入一週後過期（`expires_at`）；請在過期前重新登入。
- **預留位置。** 將 `YOUR_HOST` 替換為物流商戶的主機名稱，將 `ACCESS_TOKEN` 替換為登入步驟回傳的權杖。
- **JSON 錯誤。** 每個請求都傳送 `Accept: application/json`，使驗證錯誤以 JSON 回傳，而不是重新導向。

## 4. 以客戶身分登入

登入以客戶的電子郵件和密碼換取 Bearer 權杖。本專題之後的每個呼叫都傳送該權杖。

**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" \
  -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`：在每個請求中以 `Authorization: Bearer ACCESS_TOKEN` 傳送。GraphQL 請求提交至 `POST /api/graphql`，並使用相同的請求標頭。
- `expires_at` / `expires_timestamp`：請在此時間之前安排重新登入。

**驗證：** 回應帶有 `result: true` 和 `access_token`。未攜帶權杖的請求回傳 `401`；使用非客戶帳號或未開通 API 權限的帳號登入，同樣回傳 `401`。

## 5. 列出分配給客戶的服務

服務列表告訴整合方可以預訂哪些服務代碼，以及每項服務接受倉庫交貨、上門取件還是兩者皆可。請保存 `service_code`；之後的每個服務呼叫都使用它。

**REST：** `GET /api/v1/customer/shipping-orders/services` — [REST 手冊](/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`：之後每個服務呼叫的路徑參數。
- `offer_pickup` / `allow_warehouse_delivery`：`origin_type` 的允許值（`pickup` / `warehouse`）。
- `support_multi_package`：一張訂單是否可以包含多個包裹行。
- `services` 陣列為空表示未為該客戶分配任何服務。

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

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

**驗證：** 列表至少包含一項服務，且您已保存其 `service_code`（本專題中為 `intl_express`）。

## 6. 載入服務設定

設定回傳某項服務的下單表單所需的全部內容：接受交貨的倉庫、可選附加費、包裝與耗材目錄、單位，以及該服務可取件和可配送的國家。請在估價或建立任何內容之前，先據此驗證您的訂單資料。

**REST：** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/config` — [REST 手冊](/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`：`origin_type` 為 `warehouse` 時傳送的 `warehouse_id`。不在此列表中的 id 會在建立時被拒絕。
- `service.weight_mode`：價格方案所需的包裹欄位：`0` 實際重量（重量），`1` 材積重量（長、寬、高），`2` 計費重量（兩者）。`null` 表示該服務由人工定價。同時傳送重量和全部三個尺寸即可滿足所有模式。
- `delivery_allowed_countries` / `pickup_allowed_countries`：在呼叫估價之前，拒絕不在這些列表中的目的地國家或取件國家。
- `surcharges[].id`、`packagings[].id`、`products[].id`：用於可選附加費、包裝和耗材購買的 id。
- `weight_units` / `dimension_units`：包裹單位以數字傳送。請像本專題所有範例一樣傳送 `weight_unit: 2`（公斤）和 `dimension_unit: 2`（公分）；省略這些欄位時，兩者也是預設值。

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

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

**驗證：** `result` 為 `true`；對於倉庫交貨，`warehouses` 包含您打算使用的倉庫。`403` 表示該服務未分配給此客戶；`404` 表示服務代碼不存在或未啟用。

## 7. 估價

估價依服務的價格方案為貨件定價，不寫入任何內容。請向採購人員顯示總額；估價回報拒絕時，不要建立訂單。

**REST：** `POST /api/v1/customer/shipping-orders/services/{serviceCode}/estimate-price` — [REST 手冊](/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`（客戶將貨物送到倉庫；傳送 `warehouse_id`）或 `pickup`（由服務商上門取件；傳送 `pickup_postcode` 和 `pickup_country`）。只使用第 5 步允許的值。
- `packages`：每組相同包裹一行；`quantity` 為該行的倍數。
- `total` 和 `currency`：要顯示的金額。只要有任何費用尚未計算，`total` 即為 `null`。
- `needs_manual_quote` / `has_items_needing_quote`：由商戶人工為訂單定價；訂單可以建立，並在商戶設定價格後付款。
- `refused` / `refusal_message`：服務拒絕其無法定價的貨件。請不要建立訂單，而是顯示 `refusal_message`。
- 可選輸入：`surcharges`、`products`（產品 id 到數量的對應，僅在 `allow_purchase_supplies` 為 true 時生效）、`has_special_requirements`、`coupon_code`。

**驗證：** `result` 為 `true`，`refused` 為 `false`，且 `total` 有值或 `needs_manual_quote` 為 `true`。

## 8. 建立運送訂單

建立呼叫在該服務上預訂貨件。整合方將回傳的 `id` 與自己的採購訂單關聯保存；之後的每個呼叫都使用該 id。

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

傳送由您自己的固定 id（此處為採購訂單號）產生的 `Idempotency-Key`。使用相同的鍵和相同的請求本文重試時，回傳第一次的回應，帶有 `"replayed": true` 和請求標頭 `Idempotency-Replayed: true`，且不會建立第二張訂單。

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

- 建立時包裹的請求本文鍵為 `package`（估價時為 `packages`）。`quantity` 為 N 的每一行產生 N 個包裹，每個包裹都有自己的運單號。
- 必填欄位：`delivery_name`、`delivery_telephone`、`delivery_address_1`、`delivery_city`、`delivery_province`、`delivery_country`、`delivery_postcode`、`origin_type`、`package[].weight`；`warehouse` 時另需 `warehouse_id`，`pickup` 時另需 `pickup_name`、`pickup_telephone`、`pickup_address_1`、`pickup_city`、`pickup_province`、`pickup_country`、`pickup_postcode`。
- 可選欄位：`reference`（保存為訂單的 `reference_number`）、`delivery_email`、`delivery_address_2`、`scheduled_date`、`time_window`、`note`、`special_requirements`（文字行陣列，僅在服務允許時生效）、`products`、`surcharges`、`coupon_code`。
- `id`：請保存。`status` `0` 為待處理（等待付款）。
- `total_price`：第 9 步扣收的金額。訂單等待人工報價期間為 `0`。
- 訂單層級的 `tracking_number` 為 `null`；運單號位於各包裹上，在第 10 步讀取。
- 新建訂單時，介面回傳 HTTP `201`。

**驗證：** 回應帶有 `result: true` 和 `id`。使用相同的 `Idempotency-Key` 重複同一請求，回傳相同的 `id` 並帶有 `"replayed": true`。

## 9. 從帳戶餘額支付訂單

運送訂單從客戶的帳戶餘額中全額支付。已付款的訂單從待處理變為已確認，服務商開始處理。

先讀取金額：

**REST：** `GET /api/v1/customer/shipping-orders/{id}/payment-info` — [REST 手冊](/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 }
    ]
  }
}
```

然後付款：

**REST：** `POST /api/v1/customer/shipping-orders/{id}/pay` — [REST 手冊](/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`：餘額不足以支付 `remaining_balance` 時，請先為帳戶儲值再付款。
- `payment_type`：僅支援 `remaining_balance`；始終扣收全部剩餘金額。
- `data.status` `1` 為已確認。

**驗證：** 付款呼叫回傳 `result: true` 和 `status` `1`，再次呼叫 `payment-info` 回傳 `400`，因為訂單已全額支付。餘額不足時，付款呼叫回傳 `422`，且不扣收任何費用。

## 10. 查詢訂單並追蹤包裹

詳情呼叫回傳目前狀態和每個包裹的運單號。請將包裹運單號與採購訂單關聯保存；公開追蹤接受其中每一個。

**REST：** `GET /api/v1/customer/shipping-orders/{id}` — [REST 手冊](/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` 待處理，`1` 已確認，`2` 運送中，`3` 已出貨，`4` 已取消，`5` 失敗，`6` 部分已取件，`7` 已取件，`8` 處理中。
- `can_edit` / `can_cancel`：目前是否允許執行第 12 步。
- `packages[].tracking_number`：需要保存和追蹤的單號。
- `shipping_code`：倉庫交貨畫面接受的代碼；請列印在交貨單據上。

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

如需對某項服務的所有訂單對帳（例如在夜間任務中），可依篩選條件列出訂單。`id` 篩選條件比對訂單 id、運單號或參考號。

**REST：** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/orders` — [REST 手冊](/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 手冊](/api/graphql/documentation#/customer/customerShippingOrders)）

公開追蹤不需要權杖，回傳單個包裹的事件時間軸：

**REST：** `GET /api/v1/tracking/{trackingNumber}` — [REST 手冊](/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 手冊](/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
    }
  }
}
```

**驗證：** 詳情回傳本客戶的訂單，每個包裹一個運單號；對包裹運單號呼叫公開追蹤回傳 `result: true`。其他客戶的訂單 id 回傳 `404`。

## 11. 接收 Webhook

Webhook 將訂單建立、狀態變更和追蹤事件傳送到您的伺服器，整合方無需輪詢。客戶帳號自行設定其 Webhook URL 和簽名密鑰。

建立運送訂單時，服務商還會為其調度團隊建立一張關聯的取件訂單。Webhook 針對該關聯訂單傳送：其 `ref` 為 `Shipping-Pickup-{shipping order id}`（例如 `Shipping-Pickup-9001`），其每個包裹都在 `external_tracking_number` 中帶有運送包裹的運單號。請依這兩個欄位比對收到的事件。

| 設定項 | 事件 | 整合方的處理 |
|---|---|---|
| `order_create_webhook_url` | `order.created` | 透過 `ref` 和 `packages[].external_tracking_number` 將事件與運送訂單關聯 |
| `tracking_event_webhook_url` | `tracking.event` | 將事件附加到包裹時間軸 |
| `order_status_change_webhook_url` | `order.status_change` | 更新您系統中顯示的狀態 |

**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" \
  -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"
  }
}
```

- 只變更提交的鍵；空字串會清除 URL。`webhook_sign_secret` 須為 16 至 255 個字元，密鑰為空時不會傳送任何 Webhook。
- 對客戶帳號而言，`recipient_type` 為 `customer`。

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

對原始請求本文驗證 **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;
}
```

**驗證：** 完成設定呼叫後，一次測試建立會產生 `order.created` 事件，其 `ref` 為對應新運送訂單 id 的 `Shipping-Pickup-{id}`，且簽名驗證通過。

## 12. 修改或取消訂單

訂單在待處理狀態（付款前）可以更正，在待處理或已確認狀態可以取消。取消已付款的訂單時，已付金額退回帳戶額度。

如需修改，請使用與第 8 步相同的欄位重新傳送完整訂單。價格將重新計算。

**REST：** `PUT /api/v1/customer/shipping-orders/{id}` — [REST 手冊](/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
  }
}
```

如需取消：

**REST：** `POST /api/v1/customer/shipping-orders/{id}/cancel` — [REST 手冊](/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` 為已取消。關聯的取件訂單會被刪除。
- `refund_amount`：退回帳戶額度的金額；未付款訂單為 `0`。
- 在向使用者提供這些操作之前，請先讀取第 10 步的 `can_edit` 和 `can_cancel`。

**驗證：** 取消回傳 `status` `4`，詳情顯示 `status_name` `Cancelled`。再次取消，或取消處於運送中及之後狀態的訂單，回傳 `403` 及訊息 `This order can no longer be cancelled.`；修改已付款的訂單回傳 `403`。

## 13. 錯誤處理

| 情形 | HTTP 狀態 | 代碼 | 整合方的處理 |
|---|---|---|---|
| 權杖缺失、過期或無效；使用非客戶帳號或未開通 API 權限的帳號登入 | `401` | — | 重新登入；若登入本身失敗，請商戶檢查帳號類型和 API 權限 |
| 該服務未分配給此客戶 | `403` | — | 重新讀取服務列表（第 5 步），只預訂已分配的服務 |
| 從平台應用程式工作階段呼叫 API，而該應用程式已停用運送訂單 | `403` | `APP_CAPABILITY_DISABLED` | 請商戶為該應用程式啟用運送訂單 |
| 服務代碼未知或未啟用；此客戶下找不到該訂單 id | `404` | — | 重新整理服務列表；檢查已保存的訂單 id |
| 必填欄位缺失或無效 | `422` | — | 讀取回應本文中的 `errors`，更正欄位後重新傳送 |
| 服務不提供該起運類型，或倉庫不在服務的列表中 | `422` | — | 使用第 5 步和第 6 步中的 `origin_type` 和 `warehouse_id` |
| 服務無法為貨件定價，且拒絕未定價的貨件 | `422` | `unpriced_refused` | 未建立任何內容；顯示 `message`，不要原樣重試 |
| 訂購的耗材缺貨 | `422` | — | 讀取 `stock_shortages`，減少數量後重新傳送 |
| 相同的 `Idempotency-Key` 用於不同的請求本文 | `409` | `IDEMPOTENCY_CONFLICT` | 新訂單使用新的鍵；切勿將同一個鍵用於不同內容 |
| 使用該鍵的第一次請求仍在處理中時重試 | `409` | `IDEMPOTENCY_IN_PROGRESS` | 等待 `Retry-After` 秒後，使用相同的鍵和請求本文重試 |
| 餘額不足時付款 | `422` | — | 為帳戶儲值後再次付款 |
| 對已全額支付的訂單查詢付款資訊或付款 | `400` | — | 將訂單視為已付款；讀取詳情 |
| 訂單已離開待處理或已確認狀態後取消 | `403` | — | 顯示該訂單已無法取消；聯絡商戶 |
| 付款後修改 | `403` | — | 取消後建立新訂單，或聯絡商戶 |
| 估價、建立、付款或取消期間出現伺服器錯誤 | `500` | — | 重試一次；建立時使用相同的 `Idempotency-Key` 重試 |

## 驗收清單

請使用 `DEV-SHIP-001` 或 `HLI-PO-1058` 等測試參考號：

- [ ] 客戶登入回傳 `access_token`；未攜帶權杖的請求回傳 `401`。
- [ ] 服務列表不為空，且您已保存一個 `service_code`。
- [ ] 設定回傳該服務的倉庫、單位和允許的國家，且您的表單使用了這些內容。
- [ ] 估價回傳 `total`（或 `needs_manual_quote: true`），被拒絕的貨件不會被建立。
- [ ] 建立回傳 `id`；相同的 `Idempotency-Key` 和相同的請求本文回傳相同的 `id` 並帶有 `"replayed": true`。
- [ ] 付款成功且狀態變為已確認，或您已確認餘額不足時回傳 `422` 且不扣收任何費用。
- [ ] 詳情顯示本客戶的訂單，每個包裹一個運單號，且公開追蹤可以查到每個包裹。
- [ ] Webhook 已設定簽名密鑰；一次測試建立產生 `ref` 為 `Shipping-Pickup-{id}` 的 `order.created`，且簽名驗證通過。
- [ ] 取消測試訂單回傳 `status` `4` 和預期的 `refund_amount`；再次取消回傳 `403`。
