# Carrier labels

The label service buys shipping labels from the carriers connected to an account (for example UPS and Canada Post) and keeps each label as an order. An integration lists the account's shipping methods, quotes a parcel, creates the label order, buys the label at the chosen carrier service, prints the PDF, tracks the parcel and cancels labels that are not used. It is intended for online stores, warehouse systems and order-management systems that ship parcels through carriers rather than through their own drivers.

## 1. What you can build

The examples in this playbook follow one business: **Northbound Outfitters**, an online outdoor-gear shop that ships from its warehouse at 1200 Eglinton Ave E, Toronto. Its account has a Canada Post method and a UPS method. A typical order is a tent box of 4.2 kg, 60 × 30 × 25 cm, going to Calgary; orders to the United States go by UPS.

- **Carrier choice at checkout.** The store quotes the customer's cart against Canada Post, shows the services with price and transit days, and ships with the service the customer paid for.
- **One-click label printing in the warehouse.** The packing station creates the label order when a box is packed, buys the label at the chosen service and prints the carrier PDF on a thermal printer.
- **Cross-border shipments with customs data.** Orders to the United States carry item lines (description, quantity, value, HS code) so the UPS label is issued with its commercial data.
- **Automatic status updates to the customer.** The store stores the carrier tracking number, shows the public tracking timeline on the order page, and updates the order when a `tracking.event` webhook reports that the parcel was delivered.

## 2. What this playbook covers

This playbook covers the v1 label service (`/api/v1/labelservice/...`): one shipping method (one carrier account) per call. Use it when the integration already knows which shipping method it ships with, or when it maintains an existing label-service integration.

For new integrations, Uniorder (`/api/v1/uniorder/...`) is the recommended single entry point. The Uniorder playbooks, "Uniorder: one API for every shipment" and "Quote and order in one flow", quote every carrier service of the account at once (together with the business's own delivery, where it applies) and buy the label at the service chosen by returning its `rate_id`. The same calls then print, track and cancel every order.

Other playbooks cover the other shipment families:

- Delivery by the business's own drivers: "Own-fleet pickup and delivery".
- A customer account shipping through the services its business offers: "Shipping services".
- Goods held in a warehouse and shipped out on request: "Storage and ship-out".

## 3. Before you start

- **Account.** Use a business (client) account, an employee of that account, or a customer account of a business. A business account sees its own shipping methods. A customer account sees only the methods its business assigned to it, and each label it buys is charged to its balance; when the business enabled Auto Pause Label Service for that customer, a label is refused while balance plus credit does not cover it.
- **API permission.** The account must have API access enabled. Without it every label-service call returns `401` with `Unauthorized`.
- **Shipping methods.** At least one shipping method must be active on the account (for customers: assigned to the customer). Method identifiers differ per account and must not be hard-coded; read them in step 5.
- **Test data.** Use a test shipping method or a carrier sandbox where one is configured (rates then carry `test_mode: true`), and a destination you control. Cancel every test label that was bought on a live method.
- **Token handling.** Log in from your server and keep the token there. Do not place the token or the password in a browser or mobile application.
- **Placeholders.** Replace `YOUR_HOST` with your platform host and `ACCESS_TOKEN` with the token from step 4. Replace `shipping_method` values with the ids of your account.

## 4. Authenticate

Every label-service call needs a bearer token. The integration logs in once, stores `access_token` and `expires_at` on the server, and logs in again before the token expires.

**REST:** `POST /api/v1/user/login` — [REST handbook](/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`: send it on every later call as the header below.
- `expires_at`: log in again before this time.

```
Authorization: Bearer ACCESS_TOKEN
```

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

**GraphQL:** `userLogin` ([GraphQL handbook](/api/graphql/documentation#/user/userLogin))

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

## 5. List shipping methods

The method list tells the integration which carrier accounts it may ship with and which options each one accepts. Store the `id` of each method you use; it is the `shipping_method` of every later call.

**REST:** `POST /api/v1/labelservice/getShippingMethodList` — [REST handbook](/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
    }
  }
]
```

Each row has:

| Field | Use |
|---|---|
| `id` | `shipping_method` in every later call |
| `name` | Display name |
| `unique_identifier` | Stable code |
| `options.signature_option` | Signature available |
| `options.insurance_option` | Insurance available |
| `options.multi_package` | More than one piece |
| `package_type` | Accepted `package_type` codes, and whether each needs weight and dimensions |
| `from_contry_limit` | Countries the sender address may be in |
| `services` | Carriers and services behind the method; the codes can restrict a quote with `carriers` / `services` |

Send `"id": 59` to read one method only, or `"detail": false` to receive only `id`, `name` and `unique_identifier`.

**GraphQL:** `labelserviceGetShippingMethodList` ([GraphQL handbook](/api/graphql/documentation#/labelservice/labelserviceGetShippingMethodList)) (JSON scalar).

**Verification:** the list is not empty. You picked one `id` and you know whether that method allows signature, insurance, and multiple packages. An empty list means no method is enabled on the account.

## 6. Quote

A quote asks the carrier for prices without creating anything: the temporary order used for the request is deleted and nothing is charged. Northbound Outfitters calls it at checkout to show the Canada Post services for the cart. The body has the same shape as step 7. `shipping_method` is required.

**REST:** `POST /api/v1/labelservice/rate` — [REST handbook](/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[]`: one entry per carrier service, with `price`, `currency`, `transit_days` and `price_detail` (base charge, fuel surcharge, taxes). Show these to the customer.
- `best_rate` / `shipping_price`: the first rate returned by the method.
- A quote carries no `rate_id` and no order `id`. Labels are bought from the rates of the order created in step 7.

`weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in.

For more than one piece, send `packages: [{ ref, weight, length, width, height, weight_unit, dimension_unit }]`. `shipping_from` (address-book id) or `shipping_from_code` can replace the `sender_*` block. `carriers` and `services` restrict the quote to the listed codes.

**GraphQL:** `labelserviceRate` ([GraphQL handbook](/api/graphql/documentation#/labelservice/labelserviceRate)).

**Verification:** `result` is true and you have a price (and transit days, when the carrier sends them). If there is no rate, fix destination / package / method **before** you create.

## 7. Create the label order

This call creates the label order and asks the carrier for the rates of that shipment. It returns the order `id` and one `rate_id` per service. The label is not yet bought and nothing is charged at this point; step 8 buys it. Northbound Outfitters calls it when the box is packed and stores `id` against its order `NB-10482`.

**REST:** `POST /api/v1/labelservice/submitOrder` — [REST handbook](/api/documentation#/paths/v1-labelservice-submitOrder/post)

Same body as step 6. Send `Idempotency-Key`: a retry with the same key and the same body returns the first answer instead of creating a second order.

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

| Field | Use |
|---|---|
| `id` | Superroute order id — buy, download and cancel |
| `rates[].rate_id` | The service to buy in step 8; valid for this order only |
| `rates[].price` | Price of that service |
| `shipping_price` | Price of `best_rate` |

A shipment to the United States goes through the UPS method with the item lines required for customs. This example uses the `packages` form, which carries the items per box:

```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 handbook](/api/graphql/documentation#/labelservice/labelserviceSubmitOrder)). The REST body goes in `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"
  })
}
```

**Verification:** the answer has an `id` and at least one `rates[].rate_id`. Store both. The same `Idempotency-Key` with the same body returns the same `id` and does not create a second order.

## 8. Buy the label and read the shipment detail

This call buys the label at the chosen service, charges it, and returns the carrier tracking numbers. When the label is already bought, it only reads the detail, so a repeated call never buys twice. Northbound Outfitters sends the `rate_id` of the service the customer paid for.

**REST:** `POST /api/v1/labelservice/getShippingDetail` — [REST handbook](/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)"
}
```

| Field | Use |
|---|---|
| `mainTrackingNumber` | Carrier tracking number of the first package; give it to the customer |
| `trackingNumber` | Carrier tracking numbers of all packages, comma-separated |
| `shippingPrice` | Amount charged |
| `labelStatus` | `ready`: `shippingLabel` holds the PDF. `pending`: bought and charged, the carrier has not produced the file yet; call again later. `failed`: the background fetch gave up; calling again restarts it |
| `needSubmitShippingInformation` | `true` when this method needs the shipment information submitted (step 13) |

`type` may be `ORDER_ID` (default), `TRACKING_NUMBER` (the Superroute package number) or `THIRD_PARTY_TRACKING_NUMBER` (the carrier number). Send `rate_id` so the label is bought at the service you chose; without it the method buys at its default rate.

**GraphQL:** `labelserviceGetShippingDetail` ([GraphQL handbook](/api/graphql/documentation#/labelservice/labelserviceGetShippingDetail))

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

**Verification:** `mainTrackingNumber` is not empty and `labelStatus` is `ready` (or `pending`, then it becomes `ready` on a later call). A second call returns the same tracking number and the same `shippingPrice`.

## 9. Download the PDF

The warehouse prints the carrier's label from this call. If the label was not bought yet, the first call buys it at the default rate, as in step 8; call step 8 first to fix the service.

**REST:** `POST /api/v1/labelservice/getShippingLabel` — [REST handbook](/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+..."
```

- With `base64: 1` the body is the PDF as one base64 string; decode it and send it to the printer.
- With `base64: 0` the response is the PDF file itself (`application/pdf`).

`type` may be `ORDER_ID` (default), `TRACKING_NUMBER` or `THIRD_PARTY_TRACKING_NUMBER` (the carrier number). This is the **carrier's official label**. Piece count is fixed by the booking.

**GraphQL:** `labelserviceGetShippingLabel` ([GraphQL handbook](/api/graphql/documentation#/labelservice/labelserviceGetShippingLabel)). GraphQL always returns the base64 string.

**Verification:** the PDF opens and shows the carrier barcode / tracking number from step 8. Print one test copy, then throw it away — do not hand a test label to a carrier.

## 10. Track

The store shows the parcel's progress on the customer's order page. The public tracking endpoint needs no token and accepts the carrier number from step 8.

**REST:** `GET /api/v1/tracking/{trackingNumber}` — [REST handbook](/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 handbook](/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` is true when events come from the carrier.
- `data`: newest event first. Branch on `tracking_event_status_id` / `otep_status`, not on `description`. Early events may still be "information submitted" until the carrier scans the parcel.
- `deliveried` is true and `500` is delivered; `proofs[]` then may include signature (`type` `1`) or photo (`type` `2`).

**Verification:** the lookup returns the shipment you just created. An unknown or cancelled number returns `404` with `result: false`.

## 11. Configure event notifications

Webhooks replace polling: the store's server receives each carrier scan and updates the order without calling step 10 on a schedule.

| Setting | Event | When |
|---|---|---|
| `tracking_event_webhook_url` | `tracking.event` | Carrier scans, out for delivery, delivered |
| `order_status_change_webhook_url` | `order.status_change` | Status in your system |
| `order_create_webhook_url` | `order.created` | A label order was created (step 7); sent for label orders only when `order_created_webhook_all_types` is `1` |

**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://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
  }
}
```

- Only the keys you send are changed; an unknown key is refused with `400`.
- `changed_keys` lists what was stored. The secret is always returned masked.

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

Each `tracking.event` carries `order_id`, `tracking_event_status_id`, `tracking_event_key`, `tracking_number` and `external_tracking_number`; match it to your order by `order_id` (the `id` from step 7).

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

```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_cancel_failed_webhook_url` (`order.cancel_failed`) is not sent for label cancellations in step 12; a refused label cancellation is reported in the response of that call.

**Verification:** one test `submitOrder` produces `order.created` with the order `id`, and the first carrier scan produces `tracking.event`. An invalid signature must be rejected by the receiver with `401`.

## 12. Cancel

A label that will not be shipped is cancelled so that the carrier does not bill it; the charge is refunded to the account. Cancellation is possible only while the carrier still allows it (usually before pickup).

**REST:** `POST /api/v1/labelservice/cancelShippingLabel` — [REST handbook](/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"
}
```

- Send exactly one of `id` (the order id) or `tracking_number` (the Superroute or the carrier tracking number). Sending both returns `400`.
- `result: true`: the carrier accepted the cancellation and the label charge was refunded.

**GraphQL:** `labelserviceCancelShippingLabel` ([GraphQL handbook](/api/graphql/documentation#/labelservice/labelserviceCancelShippingLabel)).

A carrier that already has the parcel refuses: the answer is `400` with `result: false` and the carrier's message. An order whose label was never bought cannot be cancelled through this call.

**Verification:** the answer is `result: true`, and public tracking for that number returns `404`. A retry with the same `Idempotency-Key` returns the stored answer; a new cancel request for the same order returns `400` `This order already cancelled`.

## 13. Submit shipment information and close the day (only if this method requires it)

Some carriers need the day's shipments transmitted (a manifest) before pickup. Step 8 reports this per order in `needSubmitShippingInformation`. Collect those order ids during the day and submit them after the last label.

**REST:** `POST /api/v1/labelservice/submitShippingInformation` — [REST handbook](/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[]`: one line per order; resubmit the orders with `result: false` after fixing the reason in `message`.
- `404` `No eligible orders found for shipping information submission`: none of the ids has a bought label that still needs submission.

**GraphQL:** `labelserviceSubmitShippingInformation` ([GraphQL handbook](/api/graphql/documentation#/labelservice/labelserviceSubmitShippingInformation)).

Then close the day. The call takes no body and covers every label order of the caller.

**REST:** `POST /api/v1/labelservice/endofday` — [REST handbook](/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` with `There are orders need to submit shipping information`: some bought labels still need submission; submit them with `submitShippingInformation` and call again.

**GraphQL:** `labelserviceEndofday` ([GraphQL handbook](/api/graphql/documentation#/labelservice/labelserviceEndofday)).

Skip this step when no order of the day reported `needSubmitShippingInformation: true`.

**Verification:** `submitShippingInformation` reports `failure_count: 0`, and `endofday` answers `200`. Run this on a test method first.

## 14. Handling errors

Label-service errors carry a `message`; a `code` is present only where the table shows one.

| Situation | HTTP status | Code | What the integration does |
|---|---|---|---|
| Missing or expired token, or API access not enabled | `401` | — (`Unauthorized`) | Log in again; if it persists, ask the business to enable API access |
| `shipping_method` missing or not available to the caller | `400` | — | Reload the method list (step 5) and use an `id` from it |
| `package_type` not offered by the method | `400` | — | Use a key from `package_type` of step 5 |
| Invalid address or package, or the carrier returns no rate | `400` | — (carrier message) | Show the message, correct the data and quote again |
| `auto_deduplication` is `1` and the `ref` already exists | `400` | — (`exist_order_ids`) | Use the existing order from `exist_order_ids` instead of creating a new one |
| Same `Idempotency-Key` with a different body | `409` | `IDEMPOTENCY_CONFLICT` | Use a new key for a different request |
| Same `Idempotency-Key` while the first request is still running | `409` | `IDEMPOTENCY_IN_PROGRESS` | Wait for `Retry-After` seconds and retry with the same key and body |
| Customer balance plus credit does not cover the label | `400` | `INSUFFICIENT_BALANCE` | Top up with the `insufficient_balance` detail (`shortfall`, `add_funds_url`) and call step 8 again |
| Label bought, carrier file not ready yet | `400` on the first purchase, `200` later | `shipment_label_not_ready` | Wait while `labelStatus` is `pending`; call step 8 again when it is `failed` |
| Order id or number not owned by the caller | `401` | — (`Not Auth`) | Check the id and `type`; use the account that created the order |
| Cancel refused by the carrier, or the order is already cancelled | `400` | — | Treat the label as shipped (or already cancelled); do not retry |
| Tracking number unknown or cancelled | `404` | — | Stop showing the timeline for that number |
| `endofday` with shipments not yet submitted | `400` | — | Run `submitShippingInformation` for those orders, then call again |

## Test checklist

Use a destination you control and a method that can be cancelled:

- [ ] Method list is not empty; you captured one `id`.
- [ ] Rate returns a price for that method and destination.
- [ ] Submit returns an order `id` and `rates[].rate_id`; the same `Idempotency-Key` does not create a second order.
- [ ] `getShippingDetail` with the chosen `rate_id` returns `mainTrackingNumber`; a second call does not charge again.
- [ ] Label PDF opens and shows the carrier tracking number.
- [ ] Public tracking finds the shipment by that number.
- [ ] `tracking.event` (and `order.created`, when enabled) arrives; v2 signature verifies.
- [ ] A cross-border test shipment with `items` is accepted by the carrier.
- [ ] Cancel succeeds, **or** you confirmed this method cannot be cancelled after booking.
- [ ] If the method needs end-of-day, a test run completes without error.
