# Services d’expédition

L’API des services d’expédition permet à un compte client de réserver les services d’expédition que son prestataire logistique a configurés et lui a attribués. Le système du client liste les services qu’il peut utiliser, charge les règles d’un service, tarife un envoi, crée la commande d’expédition, la paie à partir du solde du compte et suit l’envoi jusqu’à la livraison. Ce guide s’adresse aux développeurs qui connectent le système d’un importateur, d’un commerçant ou d’un grossiste au prestataire logistique qui le dessert.

## 1. Ce que vous pouvez construire

Tous les exemples de ce guide reposent sur un seul scénario. **Harbourline Imports Inc.**, un importateur de thé établi à Toronto, dispose d’un compte client auprès de son prestataire logistique. Le prestataire propose le service `intl_express` (International Express) depuis son entrepôt Toronto Hub (identifiant d’entrepôt `7`). Harbourline dépose au Toronto Hub deux cartons d’échantillons de thé destinés à un distributeur de Seattle, au titre de son bon de commande `HLI-PO-1058`.

- **Réservation depuis le système des bons de commande.** Lorsqu’un bon de commande est validé, le système de Harbourline tarife l’envoi sur `intl_express`, crée la commande d’expédition avec le numéro du bon de commande comme référence et la paie à partir du solde prépayé du compte, sans que personne n’ouvre le portail du prestataire.
- **Un contrôle du prix avant engagement.** L’acheteur de Harbourline voit le fret, les suppléments, la taxe et le total pour les deux cartons avant la réservation de l’envoi, et un envoi que le service ne peut pas tarifer est arrêté avant qu’une commande existe.
- **Le statut de l’envoi dans l’ERP.** Le numéro de suivi de chaque carton est enregistré sur le bon de commande ; les webhooks transmettent le statut de la commande et l’historique de suivi à l’ERP, et une tâche nocturne effectue un rapprochement avec la liste des commandes.
- **Des modifications contrôlées.** Une réservation non payée est corrigée sur place, et une réservation qui n’est plus nécessaire est annulée, le montant payé étant reversé au crédit du compte.

## 2. Ce que couvre ce guide

Utilisez cette famille lorsque l’appelant est un **client** de l’entreprise de logistique et réserve l’un des services d’expédition propres à l’entreprise : l’entreprise fixe le plan tarifaire, les entrepôts, les suppléments et les emballages, et attribue les services au client. Le client ne voit et ne réserve que les services qui lui sont attribués.

Utilisez une autre famille dans les cas suivants :

- L’appelant est l’entreprise de logistique elle-même (un compte entreprise/client) et réserve des enlèvements et des livraisons le jour même ou locaux avec sa propre flotte : consultez **Enlèvement et livraison (flotte propre)**.
- L’appelant achète des étiquettes transporteur (par exemple UPS ou FedEx) aux tarifs négociés du compte : consultez **Étiquettes transporteur**.
- Le client stocke des marchandises dans l’entrepôt du prestataire et les expédie à partir du stock : consultez **Stockage et expédition**.

**Uniorder : une API pour chaque envoi** (`/api/v1/uniorder/...`) est le point d’entrée unique recommandé pour les nouvelles intégrations de livraison locale et d’étiquettes transporteur. Uniorder ne couvre pas les services d’expédition : les commandes de service d’expédition sont créées et gérées uniquement par les points de terminaison `/api/v1/customer/shipping-orders/...` décrits ici.

## 3. Avant de commencer

- **Type de compte.** Un compte **client** de l’entreprise de logistique, avec l’**autorisation API** activée par l’entreprise. Un compte entreprise/client ou un compte employé ne peut pas se connecter par la connexion client ci-dessous.
- **Attribution des services.** L’entreprise doit attribuer au moins un service d’expédition actif au client. Un client sans service attribué reçoit une liste de services vide.
- **Données de test.** Convenez avec l’entreprise d’un code de service de test, d’un entrepôt de test et d’un petit solde prépayé sur le compte de test. Utilisez une référence telle que `HLI-PO-1058` ou `DEV-SHIP-001` afin que les commandes de test soient faciles à retrouver et à annuler.
- **Gestion du jeton.** Appelez l’API uniquement depuis votre serveur. Ne placez ni le mot de passe ni le jeton d’accès dans des navigateurs ou des clients mobiles. Le jeton expire une semaine après la connexion (`expires_at`) ; reconnectez-vous avant son expiration.
- **Valeurs à remplacer.** Remplacez `YOUR_HOST` par le nom d’hôte de l’entreprise de logistique et `ACCESS_TOKEN` par le jeton renvoyé par l’étape de connexion.
- **Erreurs JSON.** Envoyez `Accept: application/json` avec chaque requête afin que les erreurs de validation soient renvoyées en JSON et non sous forme de redirection.

## 4. Se connecter en tant que client

La connexion échange l’adresse e-mail et le mot de passe du client contre un jeton Bearer. Chaque appel ultérieur de ce guide envoie ce jeton.

**REST :** `POST /api/v1/user/customer/login` — [Manuel REST](/api/documentation#/paths/v1-user-customer-login/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/user/customer/login \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"email":"logistics@harbourline-imports.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2026-10-05 09:15:00",
  "expires_timestamp": 1791206100,
  "name": "Harbourline Imports Inc."
}
```

- `access_token` : envoyez-le avec chaque requête sous la forme `Authorization: Bearer ACCESS_TOKEN`. GraphQL utilise le même en-tête sur `POST /api/graphql`.
- `expires_at` / `expires_timestamp` : planifiez une nouvelle connexion avant cette heure.

**Vérification :** la réponse contient `result: true` et un `access_token`. Une requête sans le jeton renvoie `401` ; une connexion avec un compte qui n’est pas un compte client, ou qui n’a pas l’autorisation API, renvoie également `401`.

## 5. Lister les services attribués au client

La liste des services indique à l’intégration les codes de service qu’elle peut réserver et si chaque service accepte un dépôt en entrepôt, un enlèvement ou les deux. Enregistrez le `service_code` ; chaque appel de service ultérieur l’utilise.

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "services": [
      {
        "id": 12,
        "service_code": "intl_express",
        "name": { "en": "International Express" },
        "offer_pickup": true,
        "allow_warehouse_delivery": true,
        "support_multi_package": true,
        "allow_special_requirements": false,
        "allow_purchase_supplies": true,
        "send_confirmation_email": true,
        "warehouses": [{ "id": 7, "name": "Toronto Hub" }]
      }
    ]
  }
}
```

- `service_code` : le paramètre de chemin de chaque appel de service ultérieur.
- `offer_pickup` / `allow_warehouse_delivery` : les valeurs autorisées de `origin_type` (`pickup` / `warehouse`).
- `support_multi_package` : indique si une commande peut comporter plusieurs lignes de colis.
- Un tableau `services` vide signifie qu’aucun service n’est attribué à ce client.

**GraphQL :** `customerShippingOrderServices` ([Manuel GraphQL](/api/graphql/documentation#/customer/customerShippingOrderServices))

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

**Vérification :** la liste contient au moins un service et vous avez enregistré son `service_code` (dans ce guide : `intl_express`).

## 6. Charger la configuration du service

La configuration renvoie tout ce dont le formulaire de commande d’un service a besoin : les entrepôts qui acceptent les dépôts, les suppléments sélectionnables, le catalogue des emballages et fournitures, les unités, et les pays depuis lesquels le service peut enlever et vers lesquels il peut livrer. Validez vos données de commande par rapport à elle avant de tarifer ou de créer quoi que ce soit.

**REST :** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/config` — [Manuel REST](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--config/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/config \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "service": {
      "service_code": "intl_express",
      "offer_pickup": true,
      "allow_warehouse_delivery": true,
      "allow_special_requirements": false,
      "allow_purchase_supplies": true,
      "weight_mode": 2,
      "volumetric_factor": 5000
    },
    "warehouses": [
      {
        "id": 7,
        "name": "Toronto Hub",
        "address_1": "250 Dundas St W",
        "city": "Toronto",
        "province": "ON",
        "country": "CA",
        "postcode": "M5T 2Z5",
        "telephone": "4165550100"
      }
    ],
    "surcharges": [],
    "has_surcharges": false,
    "packagings": [],
    "products": [],
    "weight_units": { "2": { "name": "weight_kg", "accuracy": 3 } },
    "dimension_units": { "2": { "name": "dimension_cm", "accuracy": 1 } },
    "delivery_allowed_countries": ["CA", "US"],
    "pickup_allowed_countries": ["CA"]
  }
}
```

- `warehouses[].id` : le `warehouse_id` à envoyer lorsque `origin_type` vaut `warehouse`. Un identifiant absent de cette liste est refusé lors de la création.
- `service.weight_mode` : les champs de colis exigés par le plan tarifaire : `0` poids réel (poids), `1` poids volumétrique (longueur, largeur et hauteur), `2` poids facturable (les deux). `null` signifie que le service est tarifé manuellement. Envoyez le poids et les trois dimensions pour satisfaire tous les modes.
- `delivery_allowed_countries` / `pickup_allowed_countries` : refusez un pays de destination ou d’enlèvement qui ne figure pas dans ces listes avant d’appeler l’estimation.
- `surcharges[].id`, `packagings[].id`, `products[].id` : les identifiants à utiliser pour les suppléments facultatifs, les emballages et les achats de fournitures.
- `weight_units` / `dimension_units` : les unités des colis sont envoyées sous forme de nombres. Envoyez `weight_unit: 2` (kg) et `dimension_unit: 2` (cm), comme dans tous les exemples de ce guide ; ce sont également les valeurs par défaut lorsque les champs sont omis.

**GraphQL :** `customerShippingOrderServiceConfig` ([Manuel GraphQL](/api/graphql/documentation#/customer/customerShippingOrderServiceConfig))

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

**Vérification :** `result` vaut `true` et, pour un dépôt en entrepôt, `warehouses` contient l’entrepôt que vous comptez utiliser. `403` signifie que le service n’est pas attribué à ce client ; `404` signifie que le code de service n’existe pas ou est inactif.

## 7. Estimer le prix

L’estimation tarife l’envoi selon le plan tarifaire du service sans rien enregistrer. Affichez le total à l’acheteur et ne créez pas la commande lorsque l’estimation signale un refus.

**REST :** `POST /api/v1/customer/shipping-orders/services/{serviceCode}/estimate-price` — [Manuel REST](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--estimate-price/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/estimate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_postcode": "98104",
    "delivery_country": "US",
    "packages": [{
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "needs_manual_quote": false,
    "shipping_fee": 118.40,
    "shipping_fee_total": 131.20,
    "fuel_surcharge": 12.80,
    "pickup_fee": null,
    "surcharges_total": 0,
    "sub_total": 131.20,
    "tax": { "total_tax": 17.06 },
    "total": 148.26,
    "currency": "CAD",
    "all_fees_calculated": true,
    "has_items_needing_quote": false,
    "unpriced_items": [],
    "refused": false,
    "refusal_message": null
  }
}
```

- `origin_type` : `warehouse` (le client dépose les marchandises dans un entrepôt ; envoyez `warehouse_id`) ou `pickup` (le prestataire enlève ; envoyez `pickup_postcode` et `pickup_country`). N’utilisez qu’une valeur autorisée par l’étape 5.
- `packages` : une ligne par groupe de colis identiques ; `quantity` multiplie la ligne.
- `total` et `currency` : le montant à afficher. `total` vaut `null` tant qu’un frais n’est pas calculé.
- `needs_manual_quote` / `has_items_needing_quote` : l’entreprise tarife la commande manuellement ; la commande peut être créée et est payée une fois que l’entreprise a fixé le prix.
- `refused` / `refusal_message` : le service refuse les envois qu’il ne peut pas tarifer. Ne créez pas la commande ; affichez plutôt `refusal_message`.
- Entrées facultatives : `surcharges`, `products` (une table associant l’identifiant de produit à la quantité, prise en compte uniquement lorsque `allow_purchase_supplies` vaut true), `has_special_requirements`, `coupon_code`.

**Vérification :** `result` vaut `true`, `refused` vaut `false`, et soit `total` a une valeur, soit `needs_manual_quote` vaut `true`.

## 8. Créer la commande d’expédition

L’appel de création réserve l’envoi sur le service. L’intégration enregistre l’`id` renvoyé sur son propre bon de commande ; chaque appel ultérieur utilise cet identifiant.

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

Envoyez un `Idempotency-Key` dérivé de votre propre identifiant stable (ici le numéro du bon de commande). Une nouvelle tentative avec la même clé et le même corps renvoie la première réponse avec `"replayed": true` et l’en-tête `Idempotency-Replayed: true`, et ne crée pas de seconde commande.

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: hli-po-1058" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "note": "Two cartons of sample tea, dock door B",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "price_breakdown": { "total": 148.26, "currency": "CAD" },
    "promotion_id": null,
    "tracking_number": null
  }
}
```

- La clé du corps pour les colis est `package` à la création (elle est `packages` pour l’estimation). Chaque ligne de `quantity` N devient N colis, et chaque colis reçoit son propre numéro de suivi.
- Champs obligatoires : `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`, `origin_type`, `package[].weight` ; plus `warehouse_id` pour `warehouse`, ou `pickup_name`, `pickup_telephone`, `pickup_address_1`, `pickup_city`, `pickup_province`, `pickup_country`, `pickup_postcode` pour `pickup`.
- Champs facultatifs : `reference` (enregistré comme `reference_number` de la commande), `delivery_email`, `delivery_address_2`, `scheduled_date`, `time_window`, `note`, `special_requirements` (un tableau de lignes de texte, pris en compte uniquement lorsque le service les autorise), `products`, `surcharges`, `coupon_code`.
- `id` : enregistrez-le. `status` `0` correspond à En attente (paiement attendu).
- `total_price` : le montant débité à l’étape 9. Il vaut `0` tant que la commande attend un devis manuel.
- `tracking_number` au niveau de la commande vaut `null` ; les numéros de suivi figurent sur les colis et sont lus à l’étape 10.
- Le point de terminaison répond HTTP `201` pour une nouvelle commande.

**Vérification :** la réponse contient `result: true` et un `id`. Répéter la même requête avec le même `Idempotency-Key` renvoie le même `id` avec `"replayed": true`.

## 9. Payer la commande à partir du solde du compte

Les commandes d’expédition sont payées intégralement à partir du solde du compte client. Une commande payée passe de En attente à Confirmée, et le prestataire commence à la traiter.

Lisez d’abord le montant :

**REST :** `GET /api/v1/customer/shipping-orders/{id}/payment-info` — [Manuel REST](/api/documentation#/paths/v1-customer-shipping-orders-id--payment-info/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "order_id": 9001,
    "currency": "CAD",
    "total_price": "148.26",
    "paid_amount": "0.00",
    "remaining_balance": "148.26",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "shortfall": 0,
    "payment_options": [
      { "type": "remaining_balance", "amount": 148.26 }
    ]
  }
}
```

Puis payez :

**REST :** `POST /api/v1/customer/shipping-orders/{id}/pay` — [Manuel REST](/api/documentation#/paths/v1-customer-shipping-orders-id--pay/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/9001/pay \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"payment_type":"remaining_balance"}'
```

```json
{
  "result": true,
  "message": "Payment of $148.26 processed successfully.",
  "data": {
    "order_id": 9001,
    "status": 1,
    "amount_paid": "148.26"
  }
}
```

- `has_sufficient_balance` / `shortfall` : lorsque le solde ne couvre pas `remaining_balance`, rechargez le compte avant de payer.
- `payment_type` : seul `remaining_balance` est pris en charge ; la totalité du montant restant est toujours débitée.
- `data.status` `1` correspond à Confirmée.

**Vérification :** l’appel de paiement renvoie `result: true` et `status` `1`, et un second appel à `payment-info` renvoie `400` car la commande est entièrement payée. Un appel de paiement sans solde suffisant renvoie `422` et ne débite rien.

## 10. Consulter la commande et suivre les colis

L’appel de détail renvoie le statut actuel et le numéro de suivi de chaque colis. Enregistrez les numéros de suivi des colis sur le bon de commande ; le suivi public accepte chacun d’eux.

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 1,
    "status_name": "Confirmed",
    "can_edit": false,
    "can_cancel": true,
    "shipping_code": "K7RW2Q",
    "tracking_number": null,
    "reference_number": "HLI-PO-1058",
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_address": { "name": "Cascade Tea Distributors", "city": "Seattle", "country": "US" },
    "package_count": 2,
    "packages": [
      { "id": 55101, "description": "Sample tea carton", "tracking_number": "SR123456789012", "weight": 12, "weight_unit": 2 },
      { "id": 55102, "description": "Sample tea carton", "tracking_number": "SR123456789013", "weight": 12, "weight_unit": 2 }
    ],
    "total_price": 148.26
  }
}
```

- `status` : `0` En attente, `1` Confirmée, `2` En transit, `3` Expédiée, `4` Annulée, `5` Échouée, `6` Partiellement enlevée, `7` Enlevée, `8` En traitement.
- `can_edit` / `can_cancel` : indiquent si l’étape 12 est actuellement autorisée.
- `packages[].tracking_number` : les numéros à enregistrer et à suivre.
- `shipping_code` : le code accepté par les écrans de dépôt de l’entrepôt ; imprimez-le sur les documents de dépôt.

**GraphQL :** `customerShippingOrderShow` ([Manuel GraphQL](/api/graphql/documentation#/customer/customerShippingOrderShow))

```graphql
query CustomerShippingOrderShow {
  customerShippingOrderShow(id: 9001) {
    result
    message
    data {
      id
      status
      status_name
      can_cancel
      reference_number
      packages {
        id
        tracking_number
        weight
      }
      total_price
    }
  }
}
```

Pour rapprocher toutes les commandes d’un service, par exemple dans une tâche nocturne, listez-les avec un filtre. Le filtre `id` correspond à l’identifiant de commande, à un numéro de suivi ou à la référence.

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

```bash
curl "https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders?id=HLI-PO-1058&created_at_from=2026-09-01&per_page=20" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

**GraphQL :** `customerShippingOrders` ([Manuel GraphQL](/api/graphql/documentation#/customer/customerShippingOrders))

Le suivi public ne nécessite aucun jeton et renvoie l’historique des événements d’un colis :

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

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012 \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 1,
      "otep_status": "received",
      "description": "Received at warehouse",
      "location_city": "Toronto",
      "updated_at": "2026-09-29 10:42:00"
    }
  ]
}
```

**GraphQL :** `trackingPublic` ([Manuel GraphQL](/api/graphql/documentation#/tracking/trackingPublic))

```graphql
query TrackingPublic {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    message
    deliveried
    data {
      tracking_event_status_id
      otep_status
      description
      location_city
      updated_at
    }
  }
}
```

**Vérification :** le détail renvoie la commande de ce client avec un numéro de suivi par colis, et le suivi public renvoie `result: true` pour un numéro de suivi de colis. L’identifiant de commande d’un autre client renvoie `404`.

## 11. Recevoir les webhooks

Les webhooks transmettent à votre serveur la création des commandes, les changements de statut et les événements de suivi, de sorte que l’intégration n’a pas besoin d’interroger l’API périodiquement. Le compte client configure ses propres URL de webhook et son secret de signature.

Lorsqu’une commande d’expédition est créée, le prestataire crée également une commande d’enlèvement liée pour son équipe de répartition. Les webhooks sont envoyés pour cette commande liée : son `ref` est `Shipping-Pickup-{shipping order id}` (par exemple `Shipping-Pickup-9001`), et chacun de ses colis porte le numéro de suivi du colis d’expédition dans `external_tracking_number`. Rapprochez les événements reçus sur ces deux champs.

| Réglage | Événement | Action de l’intégration |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Relier l’événement à la commande d’expédition par `ref` et `packages[].external_tracking_number` |
| `tracking_event_webhook_url` | `tracking.event` | Ajouter l’événement à l’historique du colis |
| `order_status_change_webhook_url` | `order.status_change` | Mettre à jour le statut affiché dans votre système |

**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" \
  -H "Accept: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.harbourline-imports.example/hooks/order-created",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking",
    "order_status_change_webhook_url": "https://erp.harbourline-imports.example/hooks/status",
    "webhook_sign_secret": "hli-webhook-secret-7f2c9a1e5b"
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "tracking_event_webhook_url",
    "order_status_change_webhook_url",
    "webhook_sign_secret"
  ],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "*************************1e5b",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking"
  }
}
```

- Seules les clés envoyées sont modifiées ; une chaîne vide efface une URL. `webhook_sign_secret` doit comporter de 16 à 255 caractères, et aucun webhook n’est envoyé tant que le secret est vide.
- `recipient_type` vaut `customer` pour un compte client.

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

```graphql
mutation WebhookSettingsUpdate {
  webhookSettingsUpdate(
    order_status_change_webhook_url: "https://erp.harbourline-imports.example/hooks/status"
    tracking_event_webhook_url: "https://erp.harbourline-imports.example/hooks/tracking"
  )
}
```

Vérifiez la signature **v2** sur le corps brut : `HMAC_SHA256(timestamp + "." + raw_body, secret)` par rapport à `X-Webhook-Signature-V2`. 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 :** après l’appel de configuration, une création de test produit un événement `order.created` dont le `ref` est `Shipping-Pickup-{id}` pour l’identifiant de la nouvelle commande d’expédition, et la vérification de la signature réussit.

## 12. Modifier ou annuler une commande

Une commande peut être corrigée tant qu’elle est En attente (avant le paiement) et annulée tant qu’elle est En attente ou Confirmée. L’annulation d’une commande payée reverse le montant payé au crédit du compte.

Pour modifier, renvoyez la commande complète avec les mêmes champs qu’à l’étape 8. Le prix est recalculé.

**REST :** `PUT /api/v1/customer/shipping-orders/{id}` — [Manuel REST](/api/documentation#/paths/v1-customer-shipping-orders-id/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_address_2": "Suite 210",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "promotion_id": null,
    "coupon_code": null,
    "promotion_discount": null
  }
}
```

Pour annuler :

**REST :** `POST /api/v1/customer/shipping-orders/{id}/cancel` — [Manuel REST](/api/documentation#/paths/v1-customer-shipping-orders-id--cancel/post)

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

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 4,
    "refund_amount": 148.26
  },
  "message": "Order cancelled. $148.26 refunded to your credit."
}
```

- `status` `4` correspond à Annulée. La commande d’enlèvement liée est supprimée.
- `refund_amount` : le montant reversé au crédit du compte ; `0` pour une commande non payée.
- Lisez `can_edit` et `can_cancel` à l’étape 10 avant de proposer ces actions aux utilisateurs.

**Vérification :** l’annulation renvoie `status` `4` et le détail affiche `status_name` `Cancelled`. Une seconde annulation, ou l’annulation d’une commande En transit ou à un stade ultérieur, renvoie `403` avec le message `This order can no longer be cancelled.` ; la modification d’une commande payée renvoie `403`.

## 13. Gestion des erreurs

| Situation | Statut HTTP | Code | Action de l’intégration |
|---|---|---|---|
| Jeton absent, expiré ou invalide ; connexion avec un compte non client ou sans autorisation API | `401` | — | Se reconnecter ; si la connexion elle-même échoue, demander à l’entreprise de vérifier le type de compte et l’autorisation API |
| Le service n’est pas attribué à ce client | `403` | — | Relire la liste des services (étape 5) et ne réserver que les services attribués |
| L’API est appelée depuis une session d’application de la plateforme dont l’application a les commandes d’expédition désactivées | `403` | `APP_CAPABILITY_DISABLED` | Demander à l’entreprise d’activer les commandes d’expédition pour l’application |
| Code de service inconnu ou inactif ; identifiant de commande introuvable pour ce client | `404` | — | Actualiser la liste des services ; vérifier l’identifiant de commande enregistré |
| Champ obligatoire absent ou invalide | `422` | — | Lire `errors` dans le corps, corriger les champs et renvoyer |
| Type d’origine non proposé par le service, ou entrepôt absent de la liste du service | `422` | — | Utiliser un `origin_type` et un `warehouse_id` issus des étapes 5 et 6 |
| Le service ne peut pas tarifer l’envoi et refuse les envois non tarifés | `422` | `unpriced_refused` | Rien n’a été créé ; afficher `message` et ne pas réessayer sans modification |
| Fournitures commandées en rupture de stock | `422` | — | Lire `stock_shortages`, réduire les quantités et renvoyer |
| Le même `Idempotency-Key` avec un corps différent | `409` | `IDEMPOTENCY_CONFLICT` | Utiliser une nouvelle clé pour une nouvelle commande ; ne jamais réutiliser une clé pour un contenu différent |
| Une nouvelle tentative alors que la première requête avec cette clé est encore en cours de traitement | `409` | `IDEMPOTENCY_IN_PROGRESS` | Attendre `Retry-After` secondes, puis réessayer avec la même clé et le même corps |
| Paiement sans solde suffisant | `422` | — | Recharger le compte, puis payer à nouveau |
| Informations de paiement ou paiement d’une commande entièrement payée | `400` | — | Considérer la commande comme payée ; lire le détail |
| Annulation après que la commande a quitté les statuts En attente ou Confirmée | `403` | — | Indiquer que la commande ne peut plus être annulée ; contacter l’entreprise |
| Modification après paiement | `403` | — | Annuler et créer une nouvelle commande, ou contacter l’entreprise |
| Erreur serveur lors de l’estimation, de la création, du paiement ou de l’annulation | `500` | — | Réessayer une fois ; pour la création, réessayer avec le même `Idempotency-Key` |

## Liste de tests

Utilisez une référence de test telle que `DEV-SHIP-001` ou `HLI-PO-1058` :

- [ ] La connexion client renvoie `access_token` ; une requête sans le jeton renvoie `401`.
- [ ] La liste des services n’est pas vide et vous avez enregistré un `service_code`.
- [ ] La configuration renvoie les entrepôts, les unités et les pays autorisés pour ce service, et votre formulaire les utilise.
- [ ] L’estimation renvoie un `total` (ou `needs_manual_quote: true`), et un envoi refusé n’est pas créé.
- [ ] La création renvoie un `id` ; le même `Idempotency-Key` avec le même corps renvoie le même `id` avec `"replayed": true`.
- [ ] Le paiement réussit et le statut devient Confirmée, ou vous avez vérifié qu’un solde insuffisant renvoie `422` et ne débite rien.
- [ ] Le détail affiche la commande de ce client avec un numéro de suivi par colis, et le suivi public trouve chaque colis.
- [ ] Les webhooks sont configurés avec un secret de signature ; une création de test produit `order.created` avec `ref` `Shipping-Pickup-{id}` et la vérification de la signature réussit.
- [ ] L’annulation de la commande de test renvoie `status` `4` et le `refund_amount` attendu ; une seconde annulation renvoie `403`.
