# Étiquettes transporteur

Le service d’étiquettes achète des étiquettes d’expédition auprès des transporteurs connectés à un compte (par exemple UPS et Postes Canada) et conserve chaque étiquette sous forme de commande. Une intégration liste les méthodes d’expédition du compte, obtient un tarif pour un colis, crée la commande d’étiquette, achète l’étiquette au service transporteur choisi, imprime le PDF, suit le colis et annule les étiquettes non utilisées. Il est destiné aux boutiques en ligne, aux systèmes d’entrepôt et aux systèmes de gestion des commandes qui expédient des colis par des transporteurs plutôt que par leurs propres chauffeurs.

## 1. Ce que vous pouvez construire

Les exemples de ce guide suivent une seule entreprise : **Northbound Outfitters**, une boutique en ligne d’équipement de plein air qui expédie depuis son entrepôt situé au 1200 Eglinton Ave E, Toronto. Son compte dispose d’une méthode Postes Canada et d’une méthode UPS. Une commande type est une boîte de tente de 4,2 kg, 60 × 30 × 25 cm, à destination de Calgary ; les commandes vers les États-Unis partent par UPS.

- **Choix du transporteur au paiement.** La boutique obtient les tarifs Postes Canada pour le panier du client, affiche les services avec leur prix et leurs jours de transit, et expédie avec le service payé par le client.
- **Impression d’étiquette en un clic à l’entrepôt.** Le poste d’emballage crée la commande d’étiquette lorsqu’une boîte est emballée, achète l’étiquette au service choisi et imprime le PDF du transporteur sur une imprimante thermique.
- **Envois transfrontaliers avec données douanières.** Les commandes vers les États-Unis comportent des lignes d’articles (description, quantité, valeur, code SH) afin que l’étiquette UPS soit émise avec ses données commerciales.
- **Mises à jour automatiques du statut pour le client.** La boutique enregistre le numéro de suivi du transporteur, affiche la chronologie de suivi publique sur la page de la commande et met à jour la commande lorsqu’un webhook `tracking.event` signale que le colis a été livré.

## 2. Ce que couvre ce guide

Ce guide couvre le service d’étiquettes v1 (`/api/v1/labelservice/...`) : une méthode d’expédition (un compte transporteur) par appel. Utilisez-le lorsque l’intégration sait déjà avec quelle méthode d’expédition elle expédie, ou lorsqu’elle maintient une intégration existante du service d’étiquettes.

Pour les nouvelles intégrations, Uniorder (`/api/v1/uniorder/...`) est le point d’entrée unique recommandé. Les guides Uniorder, « Uniorder : une API pour chaque envoi » et « Devis et commande en un seul parcours », obtiennent en une fois les tarifs de tous les services transporteur du compte (ainsi que de la livraison propre de l’entreprise, le cas échéant) et achètent l’étiquette au service choisi en renvoyant son `rate_id`. Les mêmes appels impriment, suivent et annulent ensuite chaque commande.

D’autres guides couvrent les autres familles d’envois :

- Livraison par les propres chauffeurs de l’entreprise : « Enlèvement et livraison (flotte propre) ».
- Un compte client qui expédie par les services que son entreprise propose : « Services d’expédition ».
- Marchandises conservées dans un entrepôt et expédiées sur demande : « Stockage et expédition ».

## 3. Avant de commencer

- **Compte.** Utilisez un compte d’entreprise (client), un employé de ce compte ou un compte client d’une entreprise. Un compte d’entreprise voit ses propres méthodes d’expédition. Un compte client ne voit que les méthodes que son entreprise lui a attribuées, et chaque étiquette qu’il achète est débitée de son solde ; lorsque l’entreprise a activé la suspension automatique du service d’étiquettes (Auto Pause Label Service) pour ce client, une étiquette est refusée tant que le solde augmenté du crédit ne la couvre pas.
- **Autorisation API.** L’accès API doit être activé sur le compte. Sans cet accès, chaque appel au service d’étiquettes renvoie `401` avec `Unauthorized`.
- **Méthodes d’expédition.** Au moins une méthode d’expédition doit être active sur le compte (pour les clients : attribuée au client). Les identifiants de méthode diffèrent selon le compte et ne doivent pas être codés en dur ; lisez-les à l’étape 5.
- **Données de test.** Utilisez une méthode d’expédition de test ou un environnement de test du transporteur lorsqu’il est configuré (les tarifs portent alors `test_mode: true`), ainsi qu’une destination que vous contrôlez. Annulez chaque étiquette de test achetée sur une méthode de production.
- **Gestion du jeton.** Connectez-vous depuis votre serveur et conservez-y le jeton. Ne placez ni le jeton ni le mot de passe dans un navigateur ou une application mobile.
- **Espaces réservés.** Remplacez `YOUR_HOST` par l’hôte de votre plateforme et `ACCESS_TOKEN` par le jeton de l’étape 4. Remplacez les valeurs de `shipping_method` par les id de votre compte.

## 4. Authentification

Chaque appel au service d’étiquettes nécessite un jeton bearer. L’intégration se connecte une fois, enregistre `access_token` et `expires_at` sur le serveur et se reconnecte avant l’expiration du jeton.

**REST :** `POST /api/v1/user/login` — [Manuel 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` : envoyez-le à chaque appel ultérieur dans l’en-tête ci-dessous.
- `expires_at` : reconnectez-vous avant cette heure.

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL utilise le même en-tête sur `POST /api/graphql`.

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

**Vérification :** la connexion renvoie `access_token`. Les requêtes ultérieures sans ce jeton renvoient `401`.

## 5. Lister les méthodes d’expédition

La liste des méthodes indique à l’intégration avec quels comptes transporteur elle peut expédier et quelles options chacun accepte. Enregistrez l’`id` de chaque méthode que vous utilisez ; c’est le `shipping_method` de chaque appel ultérieur.

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

Chaque ligne contient :

| Champ | Usage |
|---|---|
| `id` | `shipping_method` dans chaque appel ultérieur |
| `name` | Nom affiché |
| `unique_identifier` | Code stable |
| `options.signature_option` | Signature disponible |
| `options.insurance_option` | Assurance disponible |
| `options.multi_package` | Plus d’une pièce |
| `package_type` | Codes `package_type` acceptés, et indication si chacun exige le poids et les dimensions |
| `from_contry_limit` | Pays dans lesquels l’adresse de l’expéditeur peut se trouver |
| `services` | Transporteurs et services associés à la méthode ; leurs codes peuvent restreindre un tarif avec `carriers` / `services` |

Envoyez `"id": 59` pour lire une seule méthode, ou `"detail": false` pour ne recevoir que `id`, `name` et `unique_identifier`.

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

**Vérification :** la liste n’est pas vide. Vous avez choisi un `id` et vous savez si cette méthode autorise la signature, l’assurance et plusieurs colis. Une liste vide signifie qu’aucune méthode n’est activée sur le compte.

## 6. Tarifer

Une demande de tarif interroge le transporteur sur les prix sans rien créer : la commande temporaire utilisée pour la requête est supprimée et rien n’est facturé. Northbound Outfitters l’appelle au paiement pour afficher les services Postes Canada correspondant au panier. Le corps a la même forme qu’à l’étape 7. `shipping_method` est obligatoire.

**REST :** `POST /api/v1/labelservice/rate` — [Manuel 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[]` : une entrée par service transporteur, avec `price`, `currency`, `transit_days` et `price_detail` (tarif de base, supplément carburant, taxes). Affichez-les au client.
- `best_rate` / `shipping_price` : le premier tarif renvoyé par la méthode.
- Un tarif ne comporte ni `rate_id` ni `id` de commande. Les étiquettes sont achetées à partir des tarifs de la commande créée à l’étape 7.

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

Pour plus d’une pièce, envoyez `packages: [{ ref, weight, length, width, height, weight_unit, dimension_unit }]`. `shipping_from` (id du carnet d’adresses) ou `shipping_from_code` peut remplacer le bloc `sender_*`. `carriers` et `services` restreignent le tarif aux codes indiqués.

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

**Vérification :** `result` est true et vous disposez d’un prix (et de jours de transit, lorsque le transporteur les envoie). S’il n’y a aucun tarif, corrigez la destination / le colis / la méthode **avant** de créer.

## 7. Créer la commande d’étiquette

Cet appel crée la commande d’étiquette et demande au transporteur les tarifs de cet envoi. Il renvoie l’`id` de la commande et un `rate_id` par service. À ce stade, l’étiquette n’est pas encore achetée et rien n’est facturé ; l’étape 8 l’achète. Northbound Outfitters l’appelle lorsque la boîte est emballée et enregistre l’`id` avec sa commande `NB-10482`.

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

Même corps qu’à l’étape 6. Envoyez `Idempotency-Key` : une nouvelle tentative avec la même clé et le même corps renvoie la première réponse au lieu de créer une seconde commande.

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

| Champ | Usage |
|---|---|
| `id` | Id de commande Superroute — achat, téléchargement et annulation |
| `rates[].rate_id` | Le service à acheter à l’étape 8 ; valable pour cette commande uniquement |
| `rates[].price` | Prix de ce service |
| `shipping_price` | Prix de `best_rate` |

Un envoi vers les États-Unis passe par la méthode UPS avec les lignes d’articles requises pour la douane. Cet exemple utilise la forme `packages`, qui porte les articles par boîte :

```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` ([Manuel GraphQL](/api/graphql/documentation#/labelservice/labelserviceSubmitOrder)). Le corps REST est placé dans `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"
  })
}
```

**Vérification :** la réponse comporte un `id` et au moins un `rates[].rate_id`. Enregistrez les deux. La même `Idempotency-Key` avec le même corps renvoie le même `id` et ne crée pas de seconde commande.

## 8. Acheter l’étiquette et lire le détail de l’envoi

Cet appel achète l’étiquette au service choisi, la facture et renvoie les numéros de suivi du transporteur. Lorsque l’étiquette est déjà achetée, il se limite à lire le détail ; un appel répété n’achète donc jamais deux fois. Northbound Outfitters envoie le `rate_id` du service payé par le client.

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

| Champ | Usage |
|---|---|
| `mainTrackingNumber` | Numéro de suivi transporteur du premier colis ; communiquez-le au client |
| `trackingNumber` | Numéros de suivi transporteur de tous les colis, séparés par des virgules |
| `shippingPrice` | Montant facturé |
| `labelStatus` | `ready` : `shippingLabel` contient le PDF. `pending` : achetée et facturée, le transporteur n’a pas encore produit le fichier ; rappelez plus tard. `failed` : la récupération en arrière-plan a abandonné ; un nouvel appel la relance |
| `needSubmitShippingInformation` | `true` lorsque cette méthode exige la transmission des informations d’expédition (étape 13) |

`type` peut valoir `ORDER_ID` (par défaut), `TRACKING_NUMBER` (le numéro de colis Superroute) ou `THIRD_PARTY_TRACKING_NUMBER` (le numéro du transporteur). Envoyez `rate_id` pour que l’étiquette soit achetée au service que vous avez choisi ; sans lui, la méthode achète à son tarif par défaut.

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

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

**Vérification :** `mainTrackingNumber` n’est pas vide et `labelStatus` vaut `ready` (ou `pending`, qui devient ensuite `ready` lors d’un appel ultérieur). Un second appel renvoie le même numéro de suivi et le même `shippingPrice`.

## 9. Télécharger le PDF

L’entrepôt imprime l’étiquette du transporteur à partir de cet appel. Si l’étiquette n’a pas encore été achetée, le premier appel l’achète au tarif par défaut, comme à l’étape 8 ; appelez d’abord l’étape 8 pour fixer le service.

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

- Avec `base64: 1`, le corps est le PDF sous forme d’une chaîne base64 ; décodez-la et envoyez-la à l’imprimante.
- Avec `base64: 0`, la réponse est le fichier PDF lui-même (`application/pdf`).

`type` peut valoir `ORDER_ID` (par défaut), `TRACKING_NUMBER` ou `THIRD_PARTY_TRACKING_NUMBER` (le numéro du transporteur). Il s’agit de l’**étiquette officielle du transporteur**. Le nombre de pièces est fixé par la réservation.

**GraphQL :** `labelserviceGetShippingLabel` ([Manuel GraphQL](/api/graphql/documentation#/labelservice/labelserviceGetShippingLabel)). GraphQL renvoie toujours la chaîne base64.

**Vérification :** le PDF s’ouvre et affiche le code-barres / numéro de suivi du transporteur de l’étape 8. Imprimez une copie de test, puis jetez-la — ne remettez pas une étiquette de test à un transporteur.

## 10. Suivre

La boutique affiche la progression du colis sur la page de commande du client. Le point de terminaison de suivi public ne nécessite aucun jeton et accepte le numéro du transporteur de l’étape 8.

**REST :** `GET /api/v1/tracking/{trackingNumber}` — [Manuel 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` ([Manuel 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` est true lorsque les événements proviennent du transporteur.
- `data` : l’événement le plus récent en premier. Basez la logique sur `tracking_event_status_id` / `otep_status`, pas sur `description`. Les premiers événements peuvent encore indiquer « informations transmises » jusqu’à ce que le transporteur scanne le colis.
- `deliveried` est true et `500` signifie livré ; `proofs[]` peut alors inclure une signature (`type` `1`) ou une photo (`type` `2`).

**Vérification :** la recherche renvoie l’envoi que vous venez de créer. Un numéro inconnu ou annulé renvoie `404` avec `result: false`.

## 11. Configurer les notifications d’événements

Les webhooks remplacent l’interrogation périodique : le serveur de la boutique reçoit chaque scan du transporteur et met à jour la commande sans appeler l’étape 10 à intervalles réguliers.

| Réglage | Événement | Quand |
|---|---|---|
| `tracking_event_webhook_url` | `tracking.event` | Scans du transporteur, en cours de livraison, livré |
| `order_status_change_webhook_url` | `order.status_change` | Statut dans votre système |
| `order_create_webhook_url` | `order.created` | Une commande d’étiquette a été créée (étape 7) ; envoyé pour les commandes d’étiquette uniquement lorsque `order_created_webhook_all_types` vaut `1` |

**REST :** `PUT /api/v1/webhook-settings` — [Manuel 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
  }
}
```

- Seules les clés envoyées sont modifiées ; une clé inconnue est refusée avec `400`.
- `changed_keys` liste ce qui a été enregistré. Le secret est toujours renvoyé masqué.

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

Chaque `tracking.event` porte `order_id`, `tracking_event_status_id`, `tracking_event_key`, `tracking_number` et `external_tracking_number` ; rapprochez-le de votre commande par `order_id` (l’`id` de l’étape 7).

Vérifiez **v2** sur le corps brut : `HMAC_SHA256(timestamp + "." + raw_body, secret)` contre `X-Webhook-Signature-V2`. Dédupliquez sur `X-Webhook-Event-Id`. Répondez **2xx en moins de 3 secondes**.

```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`) n’est pas envoyé pour les annulations d’étiquette de l’étape 12 ; une annulation d’étiquette refusée est signalée dans la réponse de cet appel.

**Vérification :** un `submitOrder` de test produit `order.created` avec l’`id` de la commande, et le premier scan du transporteur produit `tracking.event`. Une signature invalide doit être rejetée par le récepteur avec `401`.

## 12. Annuler

Une étiquette qui ne sera pas expédiée est annulée afin que le transporteur ne la facture pas ; le montant est remboursé sur le compte. L’annulation n’est possible que tant que le transporteur l’autorise encore (en général avant l’enlèvement).

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

- Envoyez exactement l’un des deux : `id` (l’id de la commande) ou `tracking_number` (le numéro de suivi Superroute ou celui du transporteur). L’envoi des deux renvoie `400`.
- `result: true` : le transporteur a accepté l’annulation et le montant de l’étiquette a été remboursé.

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

Un transporteur qui a déjà le colis refuse : la réponse est `400` avec `result: false` et le message du transporteur. Une commande dont l’étiquette n’a jamais été achetée ne peut pas être annulée par cet appel.

**Vérification :** la réponse est `result: true`, et le suivi public de ce numéro renvoie `404`. Une nouvelle tentative avec la même `Idempotency-Key` renvoie la réponse enregistrée ; une nouvelle demande d’annulation pour la même commande renvoie `400` `This order already cancelled`.

## 13. Transmettre les informations d’expédition et clôturer la journée (seulement si cette méthode l’exige)

Certains transporteurs exigent la transmission des envois du jour (un manifeste) avant l’enlèvement. L’étape 8 l’indique pour chaque commande dans `needSubmitShippingInformation`. Rassemblez ces id de commande au cours de la journée et transmettez-les après la dernière étiquette.

**REST :** `POST /api/v1/labelservice/submitShippingInformation` — [Manuel 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[]` : une ligne par commande ; transmettez à nouveau les commandes avec `result: false` après avoir corrigé la cause indiquée dans `message`.
- `404` `No eligible orders found for shipping information submission` : aucun des id ne correspond à une étiquette achetée qui doit encore être transmise.

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

Clôturez ensuite la journée. L’appel ne prend aucun corps et couvre toutes les commandes d’étiquette de l’appelant.

**REST :** `POST /api/v1/labelservice/endofday` — [Manuel 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` avec `There are orders need to submit shipping information` : certaines étiquettes achetées doivent encore être transmises ; transmettez-les avec `submitShippingInformation` et rappelez.

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

Sautez cette étape lorsqu’aucune commande de la journée n’a signalé `needSubmitShippingInformation: true`.

**Vérification :** `submitShippingInformation` indique `failure_count: 0`, et `endofday` répond `200`. Exécutez-le d’abord sur une méthode de test.

## 14. Gestion des erreurs

Les erreurs du service d’étiquettes portent un `message` ; un `code` n’est présent que lorsque le tableau en indique un.

| Situation | Statut HTTP | Code | Action de l’intégration |
|---|---|---|---|
| Jeton manquant ou expiré, ou accès API non activé | `401` | — (`Unauthorized`) | Se reconnecter ; si le problème persiste, demander à l’entreprise d’activer l’accès API |
| `shipping_method` manquant ou non disponible pour l’appelant | `400` | — | Recharger la liste des méthodes (étape 5) et utiliser un `id` de cette liste |
| `package_type` non proposé par la méthode | `400` | — | Utiliser une clé de `package_type` de l’étape 5 |
| Adresse ou colis invalide, ou le transporteur ne renvoie aucun tarif | `400` | — (message du transporteur) | Afficher le message, corriger les données et redemander un tarif |
| `auto_deduplication` vaut `1` et la `ref` existe déjà | `400` | — (`exist_order_ids`) | Utiliser la commande existante de `exist_order_ids` au lieu d’en créer une nouvelle |
| Même `Idempotency-Key` avec un corps différent | `409` | `IDEMPOTENCY_CONFLICT` | Utiliser une nouvelle clé pour une requête différente |
| Même `Idempotency-Key` pendant que la première requête est encore en cours | `409` | `IDEMPOTENCY_IN_PROGRESS` | Attendre `Retry-After` secondes et réessayer avec la même clé et le même corps |
| Le solde du client augmenté du crédit ne couvre pas l’étiquette | `400` | `INSUFFICIENT_BALANCE` | Approvisionner le compte à l’aide du détail `insufficient_balance` (`shortfall`, `add_funds_url`) et rappeler l’étape 8 |
| Étiquette achetée, fichier du transporteur pas encore prêt | `400` au premier achat, `200` ensuite | `shipment_label_not_ready` | Attendre tant que `labelStatus` vaut `pending` ; rappeler l’étape 8 lorsqu’il vaut `failed` |
| Id ou numéro de commande n’appartenant pas à l’appelant | `401` | — (`Not Auth`) | Vérifier l’id et le `type` ; utiliser le compte qui a créé la commande |
| Annulation refusée par le transporteur, ou commande déjà annulée | `400` | — | Considérer l’étiquette comme expédiée (ou déjà annulée) ; ne pas réessayer |
| Numéro de suivi inconnu ou annulé | `404` | — | Cesser d’afficher la chronologie pour ce numéro |
| `endofday` avec des envois pas encore transmis | `400` | — | Exécuter `submitShippingInformation` pour ces commandes, puis rappeler |

## Liste de tests

Utilisez une destination que vous contrôlez et une méthode annulable :

- [ ] La liste des méthodes n’est pas vide ; vous avez relevé un `id`.
- [ ] Le tarif renvoie un prix pour cette méthode et cette destination.
- [ ] Submit renvoie un `id` de commande et des `rates[].rate_id` ; la même `Idempotency-Key` ne crée pas de seconde commande.
- [ ] `getShippingDetail` avec le `rate_id` choisi renvoie `mainTrackingNumber` ; un second appel ne facture pas à nouveau.
- [ ] Le PDF de l’étiquette s’ouvre et affiche le numéro de suivi du transporteur.
- [ ] Le suivi public trouve l’envoi par ce numéro.
- [ ] `tracking.event` (et `order.created`, lorsqu’il est activé) arrive ; la signature v2 est vérifiée.
- [ ] Un envoi transfrontalier de test avec `items` est accepté par le transporteur.
- [ ] L’annulation réussit, **ou** vous avez confirmé que cette méthode ne peut pas être annulée après réservation.
- [ ] Si la méthode exige la clôture de fin de journée, un essai se termine sans erreur.
