# 承運商打單

面單服務從帳號所連接的承運商（例如 UPS 和 Canada Post）購買運送面單，並將每張面單作為一張訂單保存。整合方列出帳號的運送方式、對包裹詢價、建立面單訂單、依所選承運商服務購買面單、列印 PDF、追蹤包裹，並取消未使用的面單。本專題面向透過承運商而非自有司機寄送包裹的網路商店、倉儲系統和訂單管理系統。

## 1. 可以建構的應用

本專題中的範例均以同一家商戶為例：**Northbound Outfitters**，一家戶外用品網路商店，從位於 1200 Eglinton Ave E, Toronto 的倉庫出貨。其帳號有一個 Canada Post 運送方式和一個 UPS 運送方式。一張典型訂單是一個 4.2 kg、60 × 30 × 25 cm 的帳篷箱，寄往 Calgary；寄往美國的訂單使用 UPS。

- **結帳時選擇承運商。** 網路商店針對 Canada Post 為客戶的購物車詢價，顯示各項服務的價格和運送天數，並依客戶付費的服務出貨。
- **倉庫一鍵列印面單。** 包裝工作站在箱子打包完成時建立面單訂單，依所選服務購買面單，並在感熱式印表機上列印承運商 PDF。
- **附帶報關資料的跨境貨件。** 寄往美國的訂單帶有貨物明細行（描述、數量、價值、HS 編碼），使 UPS 面單附帶商業資料開立。
- **自動向客戶更新狀態。** 網路商店保存承運商運單號，在訂單頁面上顯示公開追蹤時間軸，並在 `tracking.event` Webhook 回報包裹已送達時更新訂單。

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

本專題介紹 v1 面單服務（`/api/v1/labelservice/...`）：每次呼叫對應一個運送方式（一個承運商帳號）。當整合方已確定使用哪種運送方式出貨，或需要維護現有的面單服務整合時，請使用本專題。

對於新整合，建議使用 Uniorder（`/api/v1/uniorder/...`）作為統一入口。Uniorder 專題「Uniorder：所有貨件統一一個介面」和「一次詢價與下單」一次對帳號的所有承運商服務詢價（適用時同時包含商戶自有配送），並透過回傳所選服務的 `rate_id` 購買該服務的面單。之後使用相同的呼叫對每張訂單進行列印、追蹤和取消。

其他專題涵蓋其他貨件類型：

- 由商戶自有司機配送：「自有車隊取件與配送」。
- 客戶帳號透過其所屬商戶提供的服務出貨：「運送服務」。
- 貨物存放在倉庫中並依請求出庫：「倉儲與出庫」。

## 3. 開始之前

- **帳號。** 使用商戶（客戶）帳號、該帳號的員工，或某商戶下的客戶帳號。商戶帳號可以看到自己的運送方式。客戶帳號只能看到其商戶分配給它的運送方式，所購買的每張面單均從其餘額中扣款；若商戶為該客戶啟用了「自動暫停面單服務」，當餘額加信用額度不足以支付面單時，面單將被拒絕。
- **API 權限。** 帳號必須已開通 API 存取。未開通時，每個面單服務呼叫都回傳 `401` 及 `Unauthorized`。
- **運送方式。** 帳號上必須至少有一個有效的運送方式（對客戶而言：已分配給該客戶）。運送方式識別碼因帳號而異，不得寫死在程式中；請在第 5 步讀取。
- **測試資料。** 在已設定的情況下，使用測試運送方式或承運商沙箱（此時報價帶有 `test_mode: true`），以及您可控制的目的地。在正式運送方式上購買的每張測試面單都應取消。
- **權杖處理。** 從您的伺服器登入，並將權杖保存在伺服器上。不要將權杖或密碼放在瀏覽器或行動應用程式中。
- **預留位置。** 將 `YOUR_HOST` 替換為您的平台主機，將 `ACCESS_TOKEN` 替換為第 4 步取得的權杖。將 `shipping_method` 的值替換為您帳號的 id。

## 4. 登入

每個面單服務呼叫都需要 Bearer 權杖。整合方登入一次，在伺服器上保存 `access_token` 和 `expires_at`，並在權杖過期前重新登入。

**REST：** `POST /api/v1/user/login` — [REST 手冊](/api/documentation#/paths/v1-user-login/post)

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

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

- `access_token`：在之後的每個呼叫中依下方請求標頭傳送。
- `expires_at`：在此時間之前重新登入。

```
Authorization: Bearer ACCESS_TOKEN
```

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

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

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

## 5. 列出運送方式

運送方式列表告訴整合方可以使用哪些承運商帳號出貨，以及每個帳號接受哪些選項。請保存您使用的每個運送方式的 `id`；它是之後每個呼叫中的 `shipping_method`。

**REST：** `POST /api/v1/labelservice/getShippingMethodList` — [REST 手冊](/api/documentation#/paths/v1-labelservice-getShippingMethodList/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingMethodList \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"detail": true}'
```

```json
[
  {
    "id": 59,
    "name": "Canada Post",
    "unique_identifier": "CPC-TOR",
    "options": {
      "signature_option": true,
      "insurance_option": true,
      "insurance_value": true,
      "multi_package": true
    },
    "package_type": {
      "parcel": {
        "name": "Parcel",
        "options": { "weight_options": true, "dimension_options": true }
      }
    },
    "from_contry_limit": ["CA"],
    "isUploadMethod": false
  },
  {
    "id": 61,
    "name": "UPS",
    "unique_identifier": "UPS-TOR",
    "options": {
      "signature_option": true,
      "insurance_option": true,
      "insurance_value": true,
      "multi_package": true
    }
  }
]
```

每一行包含：

| 欄位 | 用途 |
|---|---|
| `id` | 之後每個呼叫中的 `shipping_method` |
| `name` | 顯示名稱 |
| `unique_identifier` | 固定代碼 |
| `options.signature_option` | 是否提供簽收 |
| `options.insurance_option` | 是否提供保險 |
| `options.multi_package` | 是否支援多件 |
| `package_type` | 接受的 `package_type` 代碼，以及各代碼是否需要重量和尺寸 |
| `from_contry_limit` | 寄件地址允許所在的國家 |
| `services` | 該運送方式背後的承運商和服務；可用這些代碼透過 `carriers` / `services` 限定詢價範圍 |

傳送 `"id": 59` 只讀取一個運送方式，或傳送 `"detail": false` 只接收 `id`、`name` 和 `unique_identifier`。

**GraphQL：** `labelserviceGetShippingMethodList`（[GraphQL 手冊](/api/graphql/documentation#/labelservice/labelserviceGetShippingMethodList)）（JSON 標量）。

**驗證：** 列表不為空。您已選定一個 `id`，並了解該運送方式是否支援簽收、保險和多件包裹。列表為空表示帳號上未啟用任何運送方式。

## 6. 詢價

詢價向承運商取得價格，但不建立任何內容：請求所用的臨時訂單會被刪除，也不會產生任何費用。Northbound Outfitters 在結帳時呼叫它，為購物車顯示 Canada Post 的各項服務。請求本文結構與第 7 步相同。`shipping_method` 為必填。

**REST：** `POST /api/v1/labelservice/rate` — [REST 手冊](/api/documentation#/paths/v1-labelservice-rate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "shipping_method": 59,
    "name": "Emily Tremblay",
    "telephone": "4035550182",
    "email": "emily.tremblay@example.com",
    "address_1": "1415 17 Ave SW",
    "city": "Calgary",
    "province": "AB",
    "postcode": "T2T0C8",
    "country": "CA",
    "weight": 4.2,
    "length": 60,
    "width": 30,
    "height": 25,
    "dimension_unit": 2,
    "weight_unit": 2,
    "signature_option": 0,
    "insurance_option": 0,
    "package_type": "parcel",
    "ref": "NB-10482",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA"
  }'
```

```json
{
  "result": true,
  "shipping_price": "24.86",
  "price_details": { "shipping_fee": "24.86" },
  "rates": [
    {
      "carrier_name": "canadapost",
      "currency": "CAD",
      "service_code": "canadapost_expedited_parcel",
      "service_name": "CANADAPOST EXPEDITED PARCEL",
      "price": 24.86,
      "transit_days": 3,
      "test_mode": false
    },
    {
      "carrier_name": "canadapost",
      "currency": "CAD",
      "service_code": "canadapost_xpresspost",
      "service_name": "CANADAPOST XPRESSPOST",
      "price": 38.12,
      "transit_days": 2,
      "test_mode": false
    }
  ],
  "best_rate": {
    "service_code": "canadapost_expedited_parcel",
    "price": 24.86,
    "transit_days": 3
  }
}
```

- `rates[]`：每項承運商服務一條，帶有 `price`、`currency`、`transit_days` 和 `price_detail`（基本運費、燃油附加費、稅費）。請向客戶顯示這些資訊。
- `best_rate` / `shipping_price`：該運送方式回傳的第一條報價。
- 詢價不帶 `rate_id`，也不帶訂單 `id`。面單從第 7 步建立的訂單的報價中購買。

`weight_unit`：`1` 公克，`2` 公斤，`3` 盎司，`4` 磅。`dimension_unit`：`1` 公釐，`2` 公分，`3` 公尺，`4` 英寸。

多件包裹時，請傳送 `packages: [{ ref, weight, length, width, height, weight_unit, dimension_unit }]`。`shipping_from`（通訊錄 id）或 `shipping_from_code` 可以取代 `sender_*` 欄位組。`carriers` 和 `services` 將詢價限定為所列代碼。

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

**驗證：** `result` 為 true，且您取得了價格（承運商提供時還包括運送天數）。若沒有報價，請在建立**之前**修正目的地、包裹或運送方式。

## 7. 建立面單訂單

本呼叫建立面單訂單，並向承運商取得該貨件的報價。它回傳訂單 `id`，以及每項服務一個 `rate_id`。此時面單尚未購買，也不產生費用；第 8 步購買面單。Northbound Outfitters 在箱子打包完成時呼叫它，並將 `id` 與其訂單 `NB-10482` 關聯保存。

**REST：** `POST /api/v1/labelservice/submitOrder` — [REST 手冊](/api/documentation#/paths/v1-labelservice-submitOrder/post)

請求本文與第 6 步相同。請傳送 `Idempotency-Key`：使用相同的鍵和相同的請求本文重試時，回傳第一次的回應，而不會建立第二張訂單。

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitOrder \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10482-label" \
  -d '{
    "shipping_method": 59,
    "name": "Emily Tremblay",
    "telephone": "4035550182",
    "email": "emily.tremblay@example.com",
    "address_1": "1415 17 Ave SW",
    "city": "Calgary",
    "province": "AB",
    "postcode": "T2T0C8",
    "country": "CA",
    "weight": 4.2,
    "length": 60,
    "width": 30,
    "height": 25,
    "dimension_unit": 2,
    "weight_unit": 2,
    "signature_option": 0,
    "insurance_option": 0,
    "package_type": "parcel",
    "ref": "NB-10482",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA"
  }'
```

```json
{
  "result": true,
  "id": 128455,
  "shipping_price": "24.86",
  "price_details": { "shipping_fee": "24.86" },
  "rates": [
    {
      "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10",
      "carrier_name": "canadapost",
      "service_code": "canadapost_expedited_parcel",
      "service_name": "CANADAPOST EXPEDITED PARCEL",
      "price": 24.86,
      "currency": "CAD",
      "transit_days": 3
    },
    {
      "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c11",
      "carrier_name": "canadapost",
      "service_code": "canadapost_xpresspost",
      "service_name": "CANADAPOST XPRESSPOST",
      "price": 38.12,
      "currency": "CAD",
      "transit_days": 2
    }
  ],
  "best_rate": {
    "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10",
    "service_code": "canadapost_expedited_parcel",
    "price": 24.86
  }
}
```

| 欄位 | 用途 |
|---|---|
| `id` | Superroute 訂單 id —— 用於購買、下載和取消 |
| `rates[].rate_id` | 第 8 步要購買的服務；僅對本訂單有效 |
| `rates[].price` | 該服務的價格 |
| `shipping_price` | `best_rate` 的價格 |

寄往美國的貨件透過 UPS 運送方式寄送，並帶有報關所需的貨物明細行。本範例使用 `packages` 形式，依箱攜帶貨物明細：

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitOrder \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10497-label" \
  -d '{
    "shipping_method": 61,
    "name": "Daniel Price",
    "telephone": "2065550117",
    "email": "daniel.price@example.com",
    "address_1": "500 Mercer St",
    "city": "Seattle",
    "province": "WA",
    "postcode": "98109",
    "country": "US",
    "package_type": "parcel",
    "paid_by": 1,
    "ref": "NB-10497",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA",
    "packages": [
      {
        "ref": "NB-10497-1",
        "weight": 2.6,
        "length": 45,
        "width": 30,
        "height": 20,
        "weight_unit": 2,
        "dimension_unit": 2,
        "items": [
          {
            "name": "Down sleeping bag",
            "description": "Down-filled sleeping bag, -7 C rating",
            "quantity": 1,
            "unit_price": 289.00,
            "currency": "CAD",
            "weight": 1.6,
            "hscode": "9404400000",
            "sku": "NB-SB-7C",
            "unit": "PCS"
          },
          {
            "name": "Camp stove",
            "description": "Canister camp stove",
            "quantity": 1,
            "unit_price": 79.00,
            "currency": "CAD",
            "weight": 1.0,
            "hscode": "7321111000",
            "sku": "NB-ST-01",
            "unit": "PCS"
          }
        ]
      }
    ]
  }'
```

**GraphQL：** `labelserviceSubmitOrder`（[GraphQL 手冊](/api/graphql/documentation#/labelservice/labelserviceSubmitOrder)）。REST 請求本文放在 `input` 中：

```graphql
mutation {
  labelserviceSubmitOrder(input: {
    shipping_method: 59
    name: "Emily Tremblay"
    telephone: "4035550182"
    address_1: "1415 17 Ave SW"
    city: "Calgary"
    province: "AB"
    postcode: "T2T0C8"
    country: "CA"
    weight: 4.2
    length: 60
    width: 30
    height: 25
    dimension_unit: 2
    weight_unit: 2
    package_type: "parcel"
    ref: "NB-10482"
    sender_name: "Northbound Outfitters"
    sender_telephone: "4165550140"
    sender_address_1: "1200 Eglinton Ave E"
    sender_city: "Toronto"
    sender_province: "ON"
    sender_postcode: "M3C1H9"
    sender_country: "CA"
  })
}
```

**驗證：** 回應包含 `id` 以及至少一個 `rates[].rate_id`。請保存兩者。使用相同的 `Idempotency-Key` 和相同的請求本文回傳相同的 `id`，且不會建立第二張訂單。

## 8. 購買面單並讀取貨件詳情

本呼叫依所選服務購買面單、扣款，並回傳承運商運單號。面單已購買時，只讀取詳情，因此重複呼叫不會重複購買。Northbound Outfitters 傳送客戶付費的服務的 `rate_id`。

**REST：** `POST /api/v1/labelservice/getShippingDetail` — [REST 手冊](/api/documentation#/paths/v1-labelservice-getShippingDetail/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingDetail \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "128455",
    "type": "ORDER_ID",
    "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10"
  }'
```

```json
{
  "id": 128455,
  "shippingPrice": "24.86",
  "mainTrackingNumber": "7023210039414604",
  "trackingNumber": "7023210039414604",
  "needSubmitShippingInformation": false,
  "rate": {
    "carrier_name": "canadapost",
    "price": 24.86,
    "price_detail": [
      { "name": "Base charge", "amount": 18.40 },
      { "name": "Fuel surcharge", "amount": 3.60 },
      { "name": "GST", "amount": 1.10 }
    ],
    "tax_items": ["HST", "GST", "PST", "QST"]
  },
  "labelStatus": "ready",
  "shippingLabel": "JVBERi0xLjQKMS... (base64 encoded)"
}
```

| 欄位 | 用途 |
|---|---|
| `mainTrackingNumber` | 第一個包裹的承運商運單號；提供給客戶 |
| `trackingNumber` | 所有包裹的承運商運單號，以逗號分隔 |
| `shippingPrice` | 扣款金額 |
| `labelStatus` | `ready`：`shippingLabel` 中包含 PDF。`pending`：已購買並扣款，但承運商尚未產生檔案；請稍後再次呼叫。`failed`：背景取得已放棄；再次呼叫會重新開始取得 |
| `needSubmitShippingInformation` | 該運送方式需要提交貨件資訊時為 `true`（第 13 步） |

`type` 可以是 `ORDER_ID`（預設）、`TRACKING_NUMBER`（Superroute 包裹單號）或 `THIRD_PARTY_TRACKING_NUMBER`（承運商單號）。請傳送 `rate_id`，使面單依您所選的服務購買；未傳送時，該運送方式依其預設報價購買。

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

```graphql
mutation {
  labelserviceGetShippingDetail(
    id: "128455"
    type: "ORDER_ID"
    rate_id: "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10"
  )
}
```

**驗證：** `mainTrackingNumber` 不為空，且 `labelStatus` 為 `ready`（或為 `pending`，之後的呼叫中變為 `ready`）。第二次呼叫回傳相同的運單號和相同的 `shippingPrice`。

## 9. 下載 PDF

倉庫透過本呼叫列印承運商面單。若面單尚未購買，第一次呼叫會依預設報價購買，與第 8 步相同；如需確定服務，請先呼叫第 8 步。

**REST：** `POST /api/v1/labelservice/getShippingLabel` — [REST 手冊](/api/documentation#/paths/v1-labelservice-getShippingLabel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "128455",
    "type": "ORDER_ID",
    "base64": 1
  }'
```

```json
"JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+..."
```

- 使用 `base64: 1` 時，回應本文為一個 base64 字串形式的 PDF；解碼後傳送到印表機。
- 使用 `base64: 0` 時，回應即為 PDF 檔案本身（`application/pdf`）。

`type` 可以是 `ORDER_ID`（預設）、`TRACKING_NUMBER` 或 `THIRD_PARTY_TRACKING_NUMBER`（承運商單號）。這是**承運商官方面單**。件數由下單時確定。

**GraphQL：** `labelserviceGetShippingLabel`（[GraphQL 手冊](/api/graphql/documentation#/labelservice/labelserviceGetShippingLabel)）。GraphQL 始終回傳 base64 字串。

**驗證：** PDF 可以開啟，並顯示第 8 步的承運商條碼 / 運單號。列印一份測試副本後將其丟棄——不要將測試面單交給承運商。

## 10. 追蹤

網路商店在客戶的訂單頁面上顯示包裹的運送進度。公開追蹤介面不需要權杖，並接受第 8 步的承運商單號。

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

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

```json
{
  "result": true,
  "is_third_party_tracking": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 430,
      "otep_status": "in_transit",
      "description": "Item in transit",
      "location_city": "Mississauga",
      "updated_at_localized": "2026-09-29 18:42"
    }
  ]
}
```

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

```graphql
query {
  trackingPublic(trackingNumber: "7023210039414604") {
    result
    deliveried
    is_third_party_tracking
    data {
      tracking_event_status_id
      otep_status
      description
      updated_at_localized
    }
    third_party_info { tracking_number status carrier_tracking_link }
    proofs { file_id type full_url signed_url }
  }
}
```

- 事件來自承運商時，`is_third_party_tracking` 為 true。
- `data`：最新事件在前。請依據 `tracking_event_status_id` / `otep_status` 進行分支判斷，而不是 `description`。在承運商掃描包裹之前，早期事件可能仍為「資訊已提交」。
- `deliveried` 為 true 且 `500` 表示已送達；此時 `proofs[]` 可能包含簽名（`type` `1`）或照片（`type` `2`）。

**驗證：** 查詢回傳您剛建立的貨件。未知或已取消的單號回傳 `404` 及 `result: false`。

## 11. 設定事件通知

Webhook 取代輪詢：網路商店的伺服器接收承運商的每次掃描並更新訂單，無需定時呼叫第 10 步。

| 設定項 | 事件 | 觸發時機 |
|---|---|---|
| `tracking_event_webhook_url` | `tracking.event` | 承運商掃描、派送中、已送達 |
| `order_status_change_webhook_url` | `order.status_change` | 您系統中的狀態 |
| `order_create_webhook_url` | `order.created` | 已建立面單訂單（第 7 步）；僅當 `order_created_webhook_all_types` 為 `1` 時才會針對面單訂單傳送 |

**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://shop.northbound-outfitters.ca/hooks/superroute",
    "order_create_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_created_webhook_all_types": 1,
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "tracking_event_webhook_url",
    "order_create_webhook_url",
    "order_created_webhook_all_types",
    "webhook_sign_secret",
    "webhook_verify_ssl"
  ],
  "recipient_type": "business",
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_create_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_created_webhook_all_types": 1,
    "webhook_verify_ssl": 1
  }
}
```

- 只變更您傳送的鍵；未知的鍵將以 `400` 拒絕。
- `changed_keys` 列出已保存的內容。密鑰始終以遮罩形式回傳。

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

每個 `tracking.event` 都帶有 `order_id`、`tracking_event_status_id`、`tracking_event_key`、`tracking_number` 和 `external_tracking_number`；依 `order_id`（第 7 步的 `id`）與您的訂單對應。

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

第 12 步的面單取消不會傳送 `order_cancel_failed_webhook_url`（`order.cancel_failed`）；面單取消被拒絕時，會在該呼叫的回應中回傳。

**驗證：** 一次測試 `submitOrder` 產生帶有訂單 `id` 的 `order.created`，承運商的第一次掃描產生 `tracking.event`。接收端必須以 `401` 拒絕無效簽名。

## 12. 取消

不再寄出的面單應取消，以免承運商計費；費用將退回帳號。只有在承運商仍允許取消時才能取消（通常在攬收之前）。

**REST：** `POST /api/v1/labelservice/cancelShippingLabel` — [REST 手冊](/api/documentation#/paths/v1-labelservice-cancelShippingLabel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/cancelShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10482-cancel" \
  -d '{"id": 128455}'
```

```json
{
  "result": true,
  "message": "Shipping Label cancelled successfully"
}
```

- `id`（訂單 id）與 `tracking_number`（Superroute 或承運商運單號）只能且必須傳送其中一個。兩者都傳送時回傳 `400`。
- `result: true`：承運商已接受取消，面單費用已退回。

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

承運商已收取包裹時會拒絕取消：回應為 `400`，帶有 `result: false` 及承運商的訊息。從未購買面單的訂單無法透過本呼叫取消。

**驗證：** 回應為 `result: true`，且該單號的公開追蹤回傳 `404`。使用相同的 `Idempotency-Key` 重試時回傳已保存的回應；對同一訂單發起新的取消請求時回傳 `400` `This order already cancelled`。

## 13. 提交貨件資訊並日終結算（僅當該運送方式要求時）

部分承運商要求在攬收前傳送當天的貨件（交接清單）。第 8 步在每張訂單的 `needSubmitShippingInformation` 中回報這一點。請在當天收集這些訂單 id，並在最後一張面單之後提交。

**REST：** `POST /api/v1/labelservice/submitShippingInformation` — [REST 手冊](/api/documentation#/paths/v1-labelservice-submitShippingInformation/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitShippingInformation \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ids": [128455, 128461, 128470]}'
```

```json
{
  "result": true,
  "message": "Processed 3 orders. Success: 3, Failed: 0",
  "data": {
    "total_processed": 3,
    "success_count": 3,
    "failure_count": 0,
    "details": [
      { "order_id": 128455, "result": true, "message": "Successful" },
      { "order_id": 128461, "result": true, "message": "Successful" },
      { "order_id": 128470, "result": true, "message": "Successful" }
    ]
  }
}
```

- `details[]`：每張訂單一行；對 `result: false` 的訂單，依 `message` 修正原因後重新提交。
- `404` `No eligible orders found for shipping information submission`：所列 id 中沒有已購買面單且仍需提交的訂單。

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

然後進行日終結算。本呼叫不需要請求本文，涵蓋呼叫方的所有面單訂單。

**REST：** `POST /api/v1/labelservice/endofday` — [REST 手冊](/api/documentation#/paths/v1-labelservice-endofday/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/endofday \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "success": 0,
  "failed": 0,
  "success_ids": [],
  "failed_ids": []
}
```

- `400` 並回傳 `There are orders need to submit shipping information`：部分已購買的面單仍需提交；請透過 `submitShippingInformation` 提交後再次呼叫。

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

當天沒有任何訂單回報 `needSubmitShippingInformation: true` 時，略過本步驟。

**驗證：** `submitShippingInformation` 回報 `failure_count: 0`，且 `endofday` 回傳 `200`。請先在測試運送方式上執行。

## 14. 錯誤處理

面單服務的錯誤帶有 `message`；僅在表中列出時才帶有 `code`。

| 情形 | HTTP 狀態 | 代碼 | 整合方的處理 |
|---|---|---|---|
| 權杖缺失或已過期，或未開通 API 存取 | `401` | —（`Unauthorized`） | 重新登入；若問題持續，請商戶開通 API 存取 |
| 缺少 `shipping_method`，或呼叫方無權使用該運送方式 | `400` | — | 重新載入運送方式列表（第 5 步），並使用其中的 `id` |
| 該運送方式不提供此 `package_type` | `400` | — | 使用第 5 步 `package_type` 中的鍵 |
| 地址或包裹無效，或承運商未回傳報價 | `400` | —（承運商訊息） | 顯示該訊息，修正資料後重新詢價 |
| `auto_deduplication` 為 `1` 且 `ref` 已存在 | `400` | —（`exist_order_ids`） | 使用 `exist_order_ids` 中的既有訂單，而不是建立新訂單 |
| 相同的 `Idempotency-Key` 用於不同的請求本文 | `409` | `IDEMPOTENCY_CONFLICT` | 不同的請求使用新的鍵 |
| 第一次請求仍在處理中時使用相同的 `Idempotency-Key` | `409` | `IDEMPOTENCY_IN_PROGRESS` | 等待 `Retry-After` 秒後，使用相同的鍵和請求本文重試 |
| 客戶餘額加信用額度不足以支付面單 | `400` | `INSUFFICIENT_BALANCE` | 依 `insufficient_balance` 詳情（`shortfall`、`add_funds_url`）儲值後，再次呼叫第 8 步 |
| 面單已購買，承運商檔案尚未就緒 | 首次購買時為 `400`，之後為 `200` | `shipment_label_not_ready` | `labelStatus` 為 `pending` 時等待；為 `failed` 時再次呼叫第 8 步 |
| 訂單 id 或單號不屬於呼叫方 | `401` | —（`Not Auth`） | 檢查 id 和 `type`；使用建立該訂單的帳號 |
| 承運商拒絕取消，或訂單已取消 | `400` | — | 將該面單視為已寄出（或已取消）；不要重試 |
| 運單號未知或已取消 | `404` | — | 停止顯示該單號的時間軸 |
| 存在尚未提交的貨件時呼叫 `endofday` | `400` | — | 為這些訂單執行 `submitShippingInformation`，然後再次呼叫 |

## 驗收清單

請使用您可控制的目的地和可以取消的運送方式：

- [ ] 運送方式列表不為空；您已記錄一個 `id`。
- [ ] 詢價為該運送方式和目的地回傳價格。
- [ ] 提交回傳訂單 `id` 和 `rates[].rate_id`；相同的 `Idempotency-Key` 不會建立第二張訂單。
- [ ] 使用所選 `rate_id` 呼叫 `getShippingDetail` 回傳 `mainTrackingNumber`；第二次呼叫不會再次扣款。
- [ ] 面單 PDF 可以開啟，並顯示承運商運單號。
- [ ] 公開追蹤可以透過該單號查到貨件。
- [ ] 收到 `tracking.event`（以及啟用時的 `order.created`）；v2 簽名驗證通過。
- [ ] 帶有 `items` 的跨境測試貨件被承運商接受。
- [ ] 取消成功，**或**您已確認該運送方式在下單後無法取消。
- [ ] 若該運送方式需要日終結算，測試執行無錯誤完成。
