# Enlèvement et livraison (flotte propre)

Ce guide décrit l’API de livraison locale d’un compte entreprise : les commandes que les chauffeurs de l’entreprise livrent à un destinataire (`type` `D`) ou enlèvent chez un expéditeur (`type` `P`). Un même ensemble de points de terminaison permet de tarifer, créer, étiqueter, suivre et annuler les deux types d’arrêt, et les webhooks signalent chaque changement à votre système. Il s’adresse aux développeurs de systèmes de gestion des commandes, d’ERP et de boutiques en ligne qui confient le travail à la flotte propre de l’entreprise.

## 1. Ce que vous pouvez construire

Les exemples ci-dessous suivent une seule entreprise : **Farine & Fils**, un fournisseur de boulangerie disposant d’un dépôt au 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), qui livre des commandes de gros sur l’île de Montréal et enlève les caisses à pain vides que ses clients retournent. Une livraison type est une pile de caisses de 12 kg, de 60 × 40 × 30 cm, pour le Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), sous la commande de gros `WHS-20931`. Un enlèvement type est une pile de caisses vides de 4 kg, chez Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), sous la référence `CRT-20931`.

- **Commandes de gros transmises au dispatch depuis l’ERP.** Chaque commande de gros confirmée devient une commande de livraison avec la fenêtre de livraison matinale du café, et l’ERP enregistre le numéro de suivi renvoyé sur la ligne de commande.
- **Enlèvements pour le retour des caisses.** Lorsqu’un client signale des caisses vides, l’ERP crée une commande d’enlèvement à l’adresse du client, et un chauffeur enlève les caisses lors de la tournée suivante.
- **Impression des étiquettes au dépôt.** L’ERP télécharge le PDF de l’étiquette de chaque commande et l’imprime au quai de chargement, afin que chaque pile de caisses porte son code-barres de suivi.
- **Un portail client avec statut en temps réel.** Chaque café consulte le statut de ses livraisons et de ses enlèvements, avec la preuve de livraison, alimenté par des webhooks plutôt que par des interrogations périodiques.

## 2. Périmètre de ce guide

Utilisez ce guide lorsque les chauffeurs de l’entreprise transportent la commande : livraisons depuis le dépôt et enlèvements à l’adresse d’un client, créés un par un ou par lots via les points de terminaison `/api/v1/client/...` et `/api/v1/orders/...`.

Pour les nouvelles intégrations, Uniorder (`/api/v1/uniorder/...`) est le point d’entrée unique recommandé : il propose les mêmes livraisons en flotte propre via une seule API, ainsi que les étiquettes transporteur, à partir d’un seul devis. Consultez **Uniorder : une API pour chaque envoi** pour la vue d’ensemble et **Devis et commande en un seul parcours** pour ses requêtes étape par étape. Les points de terminaison de ce guide restent disponibles et inchangés pour les intégrations qui les utilisent.

Utilisez **Étiquettes transporteur** lorsqu’un colis est expédié par un transporteur externe sous une étiquette achetée via la plateforme. Utilisez **Services d’expédition** pour les commandes qu’un compte client réserve sur les services d’une entreprise, et **Stockage et expédition** pour les marchandises conservées en entrepôt et expédiées sur demande ; Uniorder ne s’applique pas à ces deux cas.

## 3. Avant de commencer

- **Compte.** Utilisez un compte entreprise (client), ou un compte employé de l’entreprise, disposant de l’autorisation API. La création de commandes exige en outre l’autorisation de passer des commandes ; sans elle, `POST /api/v1/client/orderCreate` renvoie `401`.
- **Zone de service.** L’adresse de livraison ou d’enlèvement doit se trouver dans une région active de l’entreprise. Pour les tests, utilisez des adresses situées dans la zone, comme celles de ce guide.
- **Données de test.** Utilisez des références de test telles que `WHS-20931` et `CRT-20931`, et annulez les commandes de test à la fin (étape 12).
- **Jetons.** Demandez le jeton d’accès depuis votre serveur et conservez-le sur celui-ci. Ne l’envoyez jamais à un navigateur ni à une application mobile.
- **Valeurs à remplacer.** Remplacez `YOUR_HOST` par l’hôte de l’API de votre environnement et `ACCESS_TOKEN` par le jeton obtenu à l’étape 4.
- **Unités.** `weight_unit` : `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit` : `1` mm, `2` cm, `3` m, `4` in. Les deux valent `1` par défaut.

## 4. Authentification

Chaque appel de ce guide, à l’exception du suivi public, est effectué au nom du compte entreprise. Connectez-vous une fois depuis votre serveur, enregistrez le jeton renvoyé et envoyez-le avec chaque requête.

**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":"dispatch@farineetfils.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
}
```

- `access_token` : placez-le dans l’en-tête de chaque requête ultérieure :

```
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. Tarifer une livraison ou un enlèvement (facultatif)

Un devis indique le prix d’un arrêt avant que la commande n’existe, par exemple pour afficher les frais de livraison sur une facture de gros. Il ne crée rien, et la création d’une commande n’exige pas de devis préalable. Définissez `type` à `D` (livraison) ou `P` (enlèvement) ; `to_postcode` est le code postal de l’arrêt.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "D",
    "from_postcode": "H4R2N6",
    "from_country": "CA",
    "to_postcode": "H2V4G7",
    "to_country": "CA",
    "packages": [{
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "18.50",
    "tax_details": [
      { "tax_name": "GST", "tax_rate": "5.00", "tax": "0.93" },
      { "tax_name": "QST", "tax_rate": "9.975", "tax": "1.85" }
    ]
  }
}
```

- `shipping_price` : le prix de l’arrêt hors taxes. Un prix vide signifie que le code postal ne se trouve pas dans une région active, ou que la grille tarifaire ne contient aucune ligne pour celui-ci.
- `price_details.tax_details` : les taxes que portera la commande ; affichez-les sur la ligne de facture.
- `currency` : la devise de tous les montants de la réponse.

Pour tarifer l’enlèvement des caisses, envoyez la même requête avec `"type": "P"`, `"to_postcode": "H4G1V5"` ainsi que le poids et les dimensions de la pile de caisses.

**GraphQL :** `ordersRate` ([Manuel GraphQL](/api/graphql/documentation#/orders/ordersRate)). Le résultat est un scalaire JSON et ne prend pas de selection set.

```graphql
mutation {
  ordersRate(
    type: "P"
    from_postcode: "H4R2N6"
    from_country: "CA"
    to_postcode: "H4G1V5"
    to_country: "CA"
    packages: [{ weight: 4, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Vérification :** `result` vaut `true` et `shipping_price` est un nombre, pour `type` `D` comme pour `type` `P`. La création d’une commande ne dépend pas de cette étape.

## 6. Créer une commande de livraison

Chaque commande de gros confirmée devient une commande de livraison. L’ERP enregistre l’`id` et le `tracking_number` renvoyés sur sa ligne de commande ; chaque appel ultérieur utilise l’un d’eux.

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

Envoyez un en-tête `Idempotency-Key`, unique pour chaque commande de gros, afin qu’une relance après un dépassement de délai ne puisse pas créer une seconde commande.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: whs-20931-delivery" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "WHS-20931",
    "name": "Marie Tremblay",
    "company_name": "Café Lumière",
    "telephone": "5145550142",
    "email": "commandes@cafelumiere.example",
    "address_1": "5400 Avenue du Parc",
    "city": "Montréal",
    "province": "QC",
    "postcode": "H2V4G7",
    "country": "Canada",
    "schedule_date": "2026-10-02",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "packages": 1,
    "packagesDetail": [{
      "ref": "WHS-20931-1",
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "delivery_instruction": "Deliver to the back door on Rue Saint-Viateur"
  }'
```

```json
{
  "result": true,
  "id": 12345,
  "ref": "WHS-20931",
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": { "shipping_fee": "18.50" },
  "tracking_number": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012", "external_tracking_number": "" }
  ]
}
```

| Champ | Signification |
|---|---|
| `type` | `D` livraison ou `P` enlèvement |
| `need_pick_up` | `0` — les marchandises sont déjà au dépôt. `1` — un chauffeur doit enlever le colis |
| `ref` | Référence externe utilisée pour la recherche et le rapprochement |
| `name` / adresse | Livraison : destinataire. Enlèvement : point d’enlèvement |
| `schedule_date`, `time_window_start`, `time_window_end` | Date de livraison (`Y-m-d`) et fenêtre dans laquelle l’arrêt doit être desservi (`Y-m-d H:i:s`) |
| `packagesDetail` | Une entrée par colis ; `ref` identifie le colis dans votre système |
| `auto_deduplication` | `1` refuse un second colis avec la même `ref` de colis |

Dans la réponse :

- `id` : l’identifiant de la commande ; enregistrez-le pour le détail de la commande et l’appel d’annulation.
- `tracking_number` : un numéro de suivi par colis ; imprimez et suivez avec ces numéros.
- `warning` : présent lorsque la commande a été créée avec un avertissement, par exemple une adresse hors de la zone de livraison que l’entreprise conserve ou met en attente. Une commande hors zone conservée peut renvoyer `shipping_price: null`.

**GraphQL :** `clientOrderCreate` ([Manuel GraphQL](/api/graphql/documentation#/client/clientOrderCreate)). Le résultat est un scalaire JSON avec le même corps que la réponse REST.

```graphql
mutation {
  clientOrderCreate(
    type: "D"
    need_pick_up: 0
    ref: "WHS-20931"
    name: "Marie Tremblay"
    company_name: "Café Lumière"
    telephone: "5145550142"
    address_1: "5400 Avenue du Parc"
    city: "Montréal"
    province: "QC"
    postcode: "H2V4G7"
    country: "Canada"
    packages: 1
    packagesDetail: [{ ref: "WHS-20931-1", weight: 12, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Vérification :** renvoyez le même corps avec le même `Idempotency-Key`. La réponse contient le même `id`, et aucune seconde commande n’est créée.

## 7. Créer une commande d’enlèvement

Une commande d’enlèvement envoie un chauffeur enlever des marchandises à une adresse ; ici, les caisses vides chez Épicerie Wellington. Elle utilise le même point de terminaison qu’une livraison : l’adresse est le point d’enlèvement, `type` vaut `P` et `need_pick_up` vaut `1`.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crt-20931-pickup" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "CRT-20931",
    "name": "Luc Gagnon",
    "company_name": "Épicerie Wellington",
    "telephone": "5145550187",
    "email": "luc@epiceriewellington.example",
    "address_1": "4100 Rue Wellington",
    "city": "Verdun",
    "province": "QC",
    "postcode": "H4G1V5",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "CRT-20931-1",
      "weight": 4,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Empty crates are stacked at the loading door"
  }'
```

```json
{
  "result": true,
  "id": 12346,
  "ref": "CRT-20931",
  "shipping_price": "12.00",
  "currency": "CAD",
  "tracking_number": ["SR123456789029"],
  "packages": [
    { "id": 67891, "ref": "CRT-20931-1", "tracking_number": "SR123456789029", "external_tracking_number": "" }
  ]
}
```

- `id` et `tracking_number` : enregistrez-les pour le retour des caisses, comme pour une livraison.
- `pickup_instruction` : affichée au chauffeur au point d’enlèvement ; `delivery_instruction` est son équivalent pour une livraison.

**Vérification :** le détail de la commande (étape 8) affiche `type` `P` et `need_pickup` `1` pour cette commande.

## 8. Consulter la commande

Le détail de la commande confirme ce qui a été enregistré et renvoie le statut actuel ; le point de terminaison de liste permet à l’ERP de rapprocher ses propres enregistrements de ceux de la plateforme.

**REST :** `GET /api/v1/orders/{orderId}` — [Manuel REST](/api/documentation#/paths/v1-orders-orderId/get)

```bash
curl https://YOUR_HOST/api/v1/orders/12345 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "business_name": "Farine & Fils",
  "order": {
    "id": 12345,
    "ref": "WHS-20931",
    "type": "D",
    "need_pickup": 0,
    "orders_status_id": 2,
    "name": "Marie Tremblay",
    "address_1": "5400 Avenue du Parc",
    "postcode": "H2V4G7",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "shipping_price": "18.50"
  },
  "tracking_numbers": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012" }
  ]
}
```

- `order.orders_status_id` : le statut de la commande ; `2` correspond à Nouveau, `12` à Annulé.
- `order.type` et `order.need_pickup` : confirment que l’arrêt a été enregistré comme livraison ou comme enlèvement.
- `tracking_numbers` : les numéros de suivi des colis de la commande.

**REST :** `GET /api/v1/orders/list` — [Manuel REST](/api/documentation#/paths/v1-orders-list/get)

```bash
curl "https://YOUR_HOST/api/v1/orders/list?page=1&per_page=50" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

La liste renvoie toutes les commandes du compte, les plus récentes d’abord, chacune avec ses colis et ses lignes d’articles. Envoyez `page` et `per_page` ensemble pour paginer (`per_page` au maximum 1000) ; sans eux, les 1000 commandes les plus récentes sont renvoyées avec un indicateur `truncated`.

**GraphQL :** `orders` ([Manuel GraphQL](/api/graphql/documentation#/orders/orders)) pour une commande et `ordersList` ([Manuel GraphQL](/api/graphql/documentation#/orders/ordersList)) pour la liste. Les deux renvoient un scalaire JSON.

```graphql
query {
  orders(orderId: "12345")
}
```

**Vérification :** la commande appartient au compte authentifié, `ref` correspond à la valeur envoyée lors de la création et `tracking_numbers` correspond à la réponse de création.

## 9. Imprimer l’étiquette locale

L’étiquette porte le code-barres de suivi que le chauffeur scanne au dépôt et à l’arrêt. Imprimez une étiquette par colis et apposez-la sur la pile de caisses.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/shipping/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "SR123456789012",
    "type": "TRACKING_NUMBER",
    "base64": 1,
    "hide_sender_address": 0,
    "hide_receiver_address": 0
  }'
```

```json
"JVBERi0xLjcKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZwo..."
```

- `type` : la manière dont `id` est interprété : `TRACKING_NUMBER` (par défaut), `ORDER_ID` ou `REF`.
- `base64` : `0` (par défaut) envoie le PDF en flux. `1` fait de tout le corps de la réponse une chaîne JSON de premier niveau contenant le PDF en base64, et non un objet avec un champ `pdf_data`. Appelez plutôt `POST /api/v2/shipping/getShippingLabel` — [Manuel REST](/api/documentation#/paths/v2-shipping-getShippingLabel/post) pour recevoir l’étiquette dans un objet JSON classique.
- `packages` : facultatif ; le nombre d’étiquettes à imprimer. Une valeur différente du nombre de colis de la commande met à jour la commande.
- `hide_sender_address` / `hide_receiver_address` : `1` laisse cette adresse vide sur l’étiquette.

**GraphQL :** `shippingGetShippingLabel` ([Manuel GraphQL](/api/graphql/documentation#/shipping/shippingGetShippingLabel)). `shippingGetShippingLabelV2` ([Manuel GraphQL](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)) renvoie toujours du JSON (`pdf_data`).

**Vérification :** le PDF décodé s’ouvre. L’étiquette de livraison affiche l’adresse du Café Lumière ; l’étiquette d’enlèvement affiche l’adresse d’Épicerie Wellington. Une adresse masquée est vide sur l’étiquette.

## 10. Suivre la commande

Le suivi public renvoie la chronologie des événements d’un colis. Il ne nécessite aucun jeton d’accès, de sorte qu’un portail client peut l’afficher directement ; la preuve de livraison ou d’enlèvement est incluse.

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

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

```json
{
  "result": true,
  "postcode": "H2V4G7",
  "deliveried": false,
  "returntosender": false,
  "rejectedbyrecipient": false,
  "data": [
    {
      "tracking_event_status_id": 100,
      "description": "Order information submitted",
      "updated_at_localized": "2026-10-01 16:42:10"
    }
  ],
  "proofs": []
}
```

La même URL accepte votre `ref` lorsqu’elle a été enregistrée comme numéro externe.

Basez la logique sur `tracking_event_status_id`, et non sur `description` ; cette chaîne suit `Accept-Language`.

| `tracking_event_status_id` | Côté | Signification |
|---|---|---|
| `100` | les deux | Commande reçue |
| `300` / `301` | livraison | En entrepôt |
| `450` | livraison | En cours de livraison |
| `500` | livraison | Livré |
| `501` | livraison | Livraison échouée, nouvelle planification nécessaire |
| `460` | enlèvement | En cours d’enlèvement |
| `510` | enlèvement | Enlevé |
| `512` | enlèvement | Enlèvement échoué, réessayer plus tard |
| `513` | enlèvement | Problème d’enlèvement |

- `data` : du plus récent au plus ancien ; la première ligne est l’état actuel.
- `deliveried` : `true` après `500`.
- `proofs[]` : sur `500` ou `510`, peut contenir `type` `1` (signature) ou `2` (photo), avec `file_id` et `signed_url`. Une photo téléversée après cet événement ne figure pas dans ce contenu ; abonnez-vous à `pod.files_updated` (étape 11).

**GraphQL :** `trackingPublic` ([Manuel GraphQL](/api/graphql/documentation#/tracking/trackingPublic)). Le résultat est typé et nécessite un selection set.

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    returntosender
    rejectedbyrecipient
    postcode
    data {
      tracking_event_status_id
      tracking_event_key
      description
      updated_at_localized
    }
    proofs { file_id type full_url signed_url }
  }
}
```

**Vérification :** juste après la création, l’événement le plus récent est `100` et `deliveried` vaut `false`. Un numéro inconnu renvoie `result: false` avec `404` ; affichez un état « introuvable » et ne générez pas d’événements de suivi.

## 11. Recevoir les webhooks

Les webhooks transmettent chaque changement à votre serveur, de sorte que l’ERP et le portail client restent à jour sans interrogation périodique. Enregistrez les URL de rappel nécessaires à ce parcours :

| Réglage | Événement | Utilisation |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Enregistrer `id` et `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | Statut visible par le client |
| `tracking_event_webhook_url` | `tracking.event` | Chronologie d’enlèvement ou de livraison |
| `pod_files_webhook_url` | `pod.files_updated` | Photo ou signature après enlèvement ou livraison |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Une annulation que vous avez envoyée a été refusée |
| `order_create_async_postback_url` | `order.create_async` | Résultat d’un lot asynchrone (étape 13) |

**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 '{
    "order_create_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "pod_files_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_cancel_failed_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "order_status_change_webhook_url",
    "tracking_event_webhook_url",
    "pod_files_webhook_url",
    "order_cancel_failed_webhook_url",
    "webhook_sign_secret"
  ],
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_verify_ssl": 1
  }
}
```

- `changed_keys` : les réglages modifiés par cet appel.
- `settings.webhook_sign_secret` : renvoyé masqué ; conservez la valeur complète uniquement sur votre serveur.

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

Côté réception, vérifiez la signature **v2** sur le corps brut : `HMAC_SHA256(timestamp + "." + raw_body, secret)` comparé à `X-Webhook-Signature-V2`, où l’horodatage est `X-Webhook-Timestamp`. Dédupliquez sur `X-Webhook-Event-Id`. Répondez **2xx en moins de 3 secondes** et traitez l’événement ensuite.

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

**Vérification :** créez une commande de test et recevez `order.created` avec le même `id` et le même `tracking_number`. Le récepteur rejette une signature invalide avec `401`, et une seconde livraison du même `X-Webhook-Event-Id` n’est pas traitée deux fois.

## 12. Annuler une commande

Annulez une commande lorsque la commande de gros est retirée ou que l’enlèvement des caisses n’est plus nécessaire. L’appel est idempotent : l’annulation d’une commande déjà annulée réussit à nouveau.

**REST :** `POST /api/v1/orders/cancel` — [Manuel REST](/api/documentation#/paths/v1-orders-cancel/post) — envoyez exactement un des champs `order_id`, `tracking_number`, `external_tracking_number`.

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tracking_number":"SR123456789012"}'
```

```json
{
  "result": true,
  "id": 12345,
  "message": "Order 12345 has been cancelled successful.",
  "already_cancelled": false
}
```

- `result` : `true` lorsque la commande est annulée.
- `already_cancelled` : `true` lorsque la commande avait été annulée avant cet appel ; traitez-le comme un succès.
- `code` : présent lorsque l’annulation est refusée ; voir l’étape 14.

**GraphQL :** `ordersCancel` ([Manuel GraphQL](/api/graphql/documentation#/orders/ordersCancel)). Le résultat est typé et nécessite un selection set.

```graphql
query {
  ordersCancel(tracking_number: "SR123456789012") {
    result
    id
    message
    already_cancelled
    code
  }
}
```

**Vérification :** le détail de la commande affiche `orders_status_id` `12`, et la même annulation renvoie `already_cancelled: true`. Lorsqu’une annulation est refusée, `order.cancel_failed` est envoyé à `order_cancel_failed_webhook_url`.

## 13. Créer des commandes par lot (facultatif)

L’ERP peut envoyer les commandes de gros et les enlèvements de caisses de la journée en une seule requête. Chaque ligne prend les mêmes champs qu’aux étapes 6 et 7 et peut être de `type` `D` ou `P`.

**REST :** `POST /api/v1/client/batchOrderCreate` — [Manuel REST](/api/documentation#/paths/v1-client-batchOrderCreate/post) — répond lorsque toutes les lignes ont été traitées.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/batchOrderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-2026-10-01" \
  -d '{
    "per_order_transaction": 1,
    "orders": [
      {
        "type": "D",
        "need_pick_up": 0,
        "ref": "WHS-20932",
        "name": "Sophie Roy",
        "company_name": "Boulangerie du Marché",
        "telephone": "5145550163",
        "address_1": "7070 Avenue Henri-Julien",
        "city": "Montréal",
        "province": "QC",
        "postcode": "H2S3S3",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "WHS-20932-1", "weight": 10, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      },
      {
        "type": "P",
        "need_pick_up": 1,
        "ref": "CRT-20932",
        "name": "Luc Gagnon",
        "company_name": "Épicerie Wellington",
        "telephone": "5145550187",
        "address_1": "4100 Rue Wellington",
        "city": "Verdun",
        "province": "QC",
        "postcode": "H4G1V5",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "CRT-20932-1", "weight": 4, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      }
    ]
  }'
```

```json
[
  { "result": true, "id": 12347, "ref": "WHS-20932", "tracking_number": ["SR123456789036"], "packages": [{ "id": 67892, "ref": "WHS-20932-1", "tracking_number": "SR123456789036", "external_tracking_number": "" }] },
  { "result": true, "id": 12348, "ref": "CRT-20932", "tracking_number": ["SR123456789043"], "packages": [{ "id": 67893, "ref": "CRT-20932-1", "tracking_number": "SR123456789043", "external_tracking_number": "" }] }
]
```

- Chaque ligne a son propre `result` ; associez-la à votre ligne de commande par `ref`. Une ligne refusée contient `message` et `skipped_ref`, et peut contenir `code` (par exemple `INSUFFICIENT_BALANCE` ou `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction` : `1` valide chaque ligne séparément, de sorte qu’une ligne en échec ne peut pas annuler les autres.
- Les lots de plus de 100 commandes reçoivent un en-tête de réponse `X-Batch-Size-Warning` ; envoyez-les au point de terminaison asynchrone.

**REST :** `POST /api/v1/client/batchOrderCreateAsync` — [Manuel REST](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — prend le même corps et renvoie immédiatement un identifiant de tâche :

```json
{ "message": "Order batch created in async,please check later.", "asyncId": 28 }
```

Interrogez `GET /api/v1/client/async/{id}` — [Manuel REST](/api/documentation#/paths/v1-client-async-id/get) — avec l’`asyncId`, ou recevez `order.create_async` à `order_create_async_postback_url`. Le résultat de la tâche est la même liste par ligne que celle du point de terminaison synchrone.

```bash
curl https://YOUR_HOST/api/v1/client/async/28 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

**GraphQL :** `clientBatchOrderCreate` ([Manuel GraphQL](/api/graphql/documentation#/client/clientBatchOrderCreate)), `clientBatchOrderCreateAsync` ([Manuel GraphQL](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)) et `clientAsync` ([Manuel GraphQL](/api/graphql/documentation#/client/clientAsync)).

**Vérification :** un lot de deux lignes renvoie deux résultats, chacun avec sa `ref`. La tâche asynchrone renvoie les mêmes lignes une fois exécutée.

## 14. Gestion des erreurs

| Situation | Statut HTTP | Code | Action de l’intégration |
|---|---|---|---|
| Un champ obligatoire est absent ou mal formé (création) | 400 | `VALIDATION_FAILED` | Corrigez le champ indiqué dans `message` et renvoyez la requête. |
| Le solde du compte ne couvre pas la commande | 400 | `INSUFFICIENT_BALANCE` | Lisez `insufficient_balance` (montant requis, disponible, manquant) ; rechargez, puis réessayez. Aucune commande n’a été créée. |
| L’adresse est hors de la zone de service et l’entreprise supprime ces commandes | 400 | `OUT_OF_DELIVERY_AREA` | Soumettez une adresse située dans la zone de service. Aucune commande n’a été créée. |
| Une `ref` de colis ou un numéro de suivi externe existe déjà (avec la déduplication activée) | 200 (`result` `false`), ou 409 avec `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | Lisez `exist_package_ref` et liez la commande existante au lieu d’en créer une nouvelle. |
| Un `Idempotency-Key` est réutilisé avec un corps différent | 409 | `IDEMPOTENCY_CONFLICT` | Utilisez une nouvelle clé pour une requête différente. |
| Une requête avec le même `Idempotency-Key` est encore en cours | 409 | `IDEMPOTENCY_IN_PROGRESS` | Attendez, puis réessayez avec la même clé. |
| Annulation sans identifiant de commande | 400 | `MISSING_IDENTIFIER` | Envoyez un des champs `order_id`, `tracking_number`, `external_tracking_number`. |
| Annulation d’une commande inexistante | 400 | `ORDER_NOT_FOUND` | Vérifiez l’`id` ou le numéro de suivi enregistré. |
| Le numéro correspond à plusieurs commandes actives | 409 | `MULTIPLE_ORDERS_MATCHED` | Annulez par `order_id`, en utilisant l’un des `matched_order_ids`. |
| La commande appartient à un autre compte | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Annulez avec le compte qui a créé la commande. |
| Le statut de la commande ne permet plus l’annulation | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Laissez la commande en l’état ; traitez le retour séparément. |
| La commande est détenue par un transporteur tiers qui ne peut pas l’annuler | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` ou `THIRD_PARTY_CANCEL_FAILED` | La commande est inchangée ; contactez l’entreprise. |
| Le jeton est absent ou expiré, ou le compte n’est pas autorisé à passer des commandes | 401 | — | Reconnectez-vous ; vérifiez les autorisations du compte. |

## Liste de tests

Utilisez des références de test telles que `WHS-20931` et `CRT-20931` :

- [ ] (Facultatif) Le devis renvoie un prix pour un code postal dans la zone avec `type` `D`.
- [ ] (Facultatif) Le devis renvoie un prix pour un code postal dans la zone avec `type` `P`.
- [ ] La création d’une livraison renvoie `id` + `tracking_number` ; le même `Idempotency-Key` ne crée pas de seconde commande.
- [ ] La création d’un enlèvement renvoie `id` + `tracking_number` ; le détail de la commande affiche `type` `P` et `need_pickup` `1`.
- [ ] Le détail de la commande et la liste affichent les deux commandes sous ce compte.
- [ ] Le PDF de l’étiquette locale s’ouvre et affiche le destinataire ou l’adresse d’enlèvement.
- [ ] Le suivi public renvoie la chronologie sans jeton ; l’événement le plus récent est `100`.
- [ ] `order.created` arrive et sa signature v2 est vérifiée.
- [ ] L’annulation renvoie `result: true`, et une seconde annulation renvoie `already_cancelled: true`.
- [ ] Un lot d’une livraison et d’un enlèvement renvoie deux résultats, chacun avec sa `ref`.
