# Stockage et expédition

L’API de stockage et d’expédition permet à un compte client d’une entreprise d’entreposage de faire entrer des marchandises en stockage, de payer la période de stockage, puis d’expédier les colis stockés à ses propres acheteurs. Elle est destinée aux marchands et aux plateformes qui conservent leur stock dans un entrepôt tiers (3PL) et qui doivent automatiser depuis leurs propres systèmes la réservation du stockage, la consultation du stock et les expéditions sortantes. Tous les appels s’exécutent en tant que compte client, jamais en tant qu’entreprise d’entreposage.

## 1. Ce que vous pouvez construire

Les exemples de ce guide suivent un seul scénario. **Northwind Outdoor**, un vendeur en ligne saisonnier d’équipement d’hiver, stocke son stock d’hiver au **Toronto Hub** (entrepôt `7`) de son 3PL du 1er novembre 2026 au 31 mars 2027. Lorsqu’un acheteur commande un carton de vestes isolantes, Northwind expédie ce carton depuis le stock à l’acheteur à Ottawa.

- **Réservation de stockage saisonnière.** Le back-office du vendeur tarife et réserve une période de stockage pour chaque carton entrant avant que les marchandises ne quittent le fournisseur, et paie les frais de stockage depuis le solde de son compte.
- **Consultation du stock en temps réel.** La boutique ou l’ERP du vendeur liste les colis que l’entrepôt a effectivement réceptionnés et qui sont encore disponibles à l’expédition, de sorte que seul le stock réel est proposé pour l’exécution des commandes.
- **Exécution des commandes depuis le stock.** Lorsqu’un acheteur passe une commande, le système du vendeur tarife l’expédition sortante, crée une demande d’expédition pour les colis stockés, la paie et enregistre le numéro de suivi pour l’acheteur.
- **Suivi du statut et correction.** Le système du vendeur lit l’état de chaque commande de stockage et de chaque expédition, suit l’envoi par le suivi public et annule une expédition qui n’est plus nécessaire tant que cela reste autorisé.

## 2. Ce que couvre ce guide

Utilisez ce guide lorsque les marchandises sont déjà stockées, ou le seront, dans l’entrepôt de l’entreprise et que l’envoi part de ce stock. Le parcours est le suivant : connexion → lire la configuration du stockage → tarifer le stockage → créer la commande de stockage → payer → lister les colis en stock → lister les services et estimer l’expédition → créer l’expédition → payer → consulter et suivre → webhooks → annuler.

D’autres guides conviennent à d’autres cas :

- **Uniorder : une API pour chaque envoi** — le point d’entrée unique recommandé (`/api/v1/uniorder/...`) pour les nouvelles intégrations qui réservent une livraison locale ou des étiquettes transporteur. Uniorder ne couvre **pas** le stockage et l’expédition ; les commandes de stockage et les expéditions sont créées uniquement par les points de terminaison client de ce guide.
- **Services d’expédition** — un client expédie des marchandises qui ne sont pas en stockage, au moyen des services d’expédition de l’entreprise.
- **Étiquettes transporteur** — une entreprise achète directement des étiquettes transporteur pour ses propres colis.
- **Enlèvement et livraison (flotte propre)** — une entreprise réserve des enlèvements et des livraisons avec sa propre flotte.

## 3. Avant de commencer

- **Type de compte.** Un compte **client** de l’entreprise d’entreposage (l’entreprise qui exploite l’entrepôt est le prestataire de services). Un jeton de compte entreprise (client professionnel) ne fonctionne pas sur les points de terminaison `/api/v1/customer/...`.
- **Autorisations.** Le compte client doit disposer de l’accès API. Les points de terminaison de stockage exigent également la capacité de stockage ; les points de terminaison d’expédition exigent que l’entreprise ait activé l’expédition (ou la consolidation) pour ce client, sinon ils répondent `403`.
- **Solde.** Les paiements de stockage et d’expédition sont prélevés sur le solde du compte client. Pour un test, demandez à l’entreprise de créditer le solde du client de test.
- **Données de test.** Un `id` d’entrepôt, au moins un `id` d’emballage si les colis personnalisés ne sont pas autorisés, et au moins un service d’expédition actif disponible depuis cet entrepôt. L’expédition ne fonctionne qu’après que l’entrepôt a **réceptionné** les colis stockés ; lors d’un test, demandez au personnel de l’entrepôt de réceptionner la commande de stockage de test.
- **Gestion du jeton.** Connectez-vous depuis votre serveur, conservez le jeton sur le serveur et ne le placez jamais dans du code de navigateur ou d’application mobile.
- **Valeurs à remplacer.** Remplacez `YOUR_HOST` par l’hôte de votre plateforme et `ACCESS_TOKEN` par le jeton obtenu à l’étape 4.

## 4. Se connecter en tant que client

Chaque appel ultérieur est autorisé par un jeton bearer client. Votre intégration se connecte une fois, conserve le jeton côté serveur et le renouvelle avant `expires_at`.

**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" \
  -d '{"email":"ops@northwind-outdoor.example","password":"your_password"}'
```

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

- `access_token` — envoyez-le à chaque requête dans l’en-tête ci-dessous.
- `expires_at` / `expires_timestamp` — reconnectez-vous avant cette heure.

```
Authorization: Bearer ACCESS_TOKEN
```

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

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

## 5. Lire la configuration du stockage

Le paquet de configuration liste les entrepôts que le client peut utiliser, le catalogue d’emballages, les unités et les suppléments. Votre intégration le lit une fois par session pour choisir l’entrepôt et construire des lignes de colis valides.

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

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

```json
{
  "result": true,
  "data": {
    "warehouses": [
      { "id": 7, "name": "Toronto Hub", "address": "10 Main St", "city": "Toronto", "province": "ON", "postcode": "M5V 2T6" }
    ],
    "packagings": [
      { "id": 1, "name": "Large Carton", "type": "Box", "length": 60, "width": 40, "height": 40, "dimension_unit": 2 }
    ],
    "dimension_units": { "1": { "name": "dimension_mm" }, "2": { "name": "dimension_cm" }, "3": { "name": "dimension_m" }, "4": { "name": "dimension_inch" } },
    "weight_units": { "1": { "name": "weight_g" }, "2": { "name": "weight_kg" }, "3": { "name": "weight_oz" }, "4": { "name": "weight_lb" } },
    "allow_custom_package": true,
    "surcharges": [],
    "form_bindings": []
  }
}
```

- `warehouses[].id` — le `warehouse_id` de chaque appel ultérieur.
- `allow_custom_package` — lorsque la valeur est `false`, chaque article de stockage doit porter un `packaging_id` provenant de `packagings[]` ; lorsqu’elle est `true`, les articles peuvent être décrits uniquement par leurs dimensions.
- `dimension_units` / `weight_units` — les codes entiers utilisés dans les lignes de colis (`2` = cm, `2` = kg).
- `form_bindings` — les formulaires que l’entreprise exige sur une commande de stockage ; envoyez leurs réponses dans `form_data` à l’étape 7.

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

```graphql
query {
  customerStorageOrderConfig
}
```

**Vérification :** vous avez relevé un `id` d’entrepôt et, si le catalogue n’est pas vide, un `id` d’emballage.

## 6. Tarifer la période de stockage

Le devis tarife la période de stockage des colis prévus avant toute réservation. Votre intégration affiche ou contrôle ce prix, puis crée la commande avec les mêmes données.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/calculate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "items": [{
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2
    }]
  }'
```

```json
{
  "success": true,
  "price": {
    "total_price": "186.45",
    "currency": "CAD",
    "shipping_price": 186.45
  }
}
```

- `success` — `true` lorsqu’un prix a été calculé.
- `price.total_price` / `price.currency` — le prix du stockage toutes taxes comprises pour la période.
- `promotion` — présent uniquement lorsqu’une promotion s’applique.

**Vérification :** `success` ou `result` est true et vous avez un prix. `warehouse_id` / dates manquants donnent `400`.

## 7. Créer la commande de stockage

La commande de stockage annonce les colis entrants à l’entrepôt et fixe la période de stockage. Votre intégration conserve l’id renvoyé ; il est nécessaire pour payer, consulter et annuler la commande.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-winter-2026-po-4471" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "notes": "Winter 2026 stock, PO 4471",
    "items": [{
      "description": "Insulated jackets, carton of 12",
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2,
      "value": 1800
    }]
  }'
```

```json
{
  "result": true,
  "message": "Storage order created",
  "data": { "id": 1024, "status": "pending payment" }
}
```

- `data.id` — l’id de la commande de stockage. Conservez-le avec votre bon de commande.
- `data.status` — `pending payment` jusqu’au paiement de la commande.
- `Idempotency-Key` — dérivez-le de votre propre identifiant stable. Une clé répétée avec le même corps rejoue la première réponse (`replayed: true`) ; la même clé avec un corps différent est refusée avec `409 IDEMPOTENCY_CONFLICT`.
- Champs obligatoires : `warehouse_id`, `start_date`, `end_date` (postérieure à `start_date`), et `items[]` avec `qty`, `length`, `width`, `height`, `dimension_unit`. Ajoutez `items[].packaging_id` lorsque `allow_custom_package` vaut `false`.

**Vérification :** la réponse contient `data.id`. Conservez cet id de commande de stockage.

## 8. Payer le stockage

Le paiement confirme la commande de stockage. Votre intégration peut d’abord lire le montant dû, puis payer depuis le solde du client.

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

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/1024/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "success": true,
  "result": true,
  "data": {
    "order_id": 1024,
    "currency": "CAD",
    "total_price": "186.45",
    "paid_amount": "0.00",
    "remaining_balance": "186.45",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "minimum_payment": "186.45"
  }
}
```

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

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

```json
{
  "success": true,
  "result": true,
  "message": "Payment of $186.45 processed successfully. Your storage order is now confirmed.",
  "new_balance": 313.55,
  "paid_amount": 186.45,
  "charge_amount": 186.45,
  "is_fully_paid": true
}
```

- `payment_type` — `full_balance` (par défaut, paie le solde restant), `minimum_payment` (paie le minimum exigé par l’entreprise), ou `custom` accompagné de `custom_amount`.
- `is_fully_paid` — `true` lorsqu’il ne reste rien à payer.
- Un solde insuffisant répond `400` avec `customer_balance` ; rechargez le solde, puis réessayez.

Consultez la commande pour confirmer son statut et, plus tard, savoir quels colis l’entrepôt a réceptionnés.

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

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

```json
{
  "result": true,
  "data": {
    "id": 1024,
    "status": "confirmed",
    "can_cancel": true,
    "store_from": "2026-11-01",
    "store_to": "2027-03-31",
    "warehouse_id": 7,
    "packages": [
      { "id": 5001, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false },
      { "id": 5002, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false }
    ],
    "total_price": 186.45,
    "currency": "CAD"
  }
}
```

- `status` — `confirmed` après le paiement ; ensuite `partial received` / `storage in progress` à mesure que les marchandises arrivent.
- `packages[].received` — `true` dès que l’entrepôt a réceptionné ce colis.
- `can_cancel` — indique si la commande de stockage peut encore être annulée.

**GraphQL :** `customerStorageOrderShow` ([Manuel GraphQL](/api/graphql/documentation#/customer/customerStorageOrderShow)) ; la liste de toutes les commandes de stockage est `customerStorageOrders` ([Manuel GraphQL](/api/graphql/documentation#/customer/customerStorageOrders)).

```graphql
query {
  customerStorageOrderShow(id: 1024) {
    result
    data {
      id
      status
      can_cancel
      packages { id description received }
    }
  }
}
```

**Vérification :** la commande de stockage est payée / confirmée. Un `400` avec `customer_balance` signifie qu’il faut recharger le solde, puis réessayer.

L’expédition ci-dessous ne fonctionne qu’après que les colis ont été **réceptionnés** en entrepôt. Pour un test, attendez que le personnel (ou une réception de test) les ait marqués comme réceptionnés, puis continuez.

## 9. Lister les articles encore en stock

Cette liste représente le stock que votre intégration peut expédier. Elle ne contient que les colis que l’entrepôt a réceptionnés et qui ne sont pas déjà verrouillés sur une autre expédition.

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

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/available-items?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "warehouses": [{ "id": 7, "name": "Toronto Hub", "available_count": 2 }],
    "storage_orders": [{
      "id": 1024,
      "warehouse_id": 7,
      "packages": [
        { "id": 5001, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 },
        { "id": 5002, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 }
      ]
    }]
  }
}
```

- `storage_orders[].packages[].id` — les `storage_package_ids` à expédier à l’étape 10.
- `warehouses[].available_count` — le nombre de colis disponibles par entrepôt.

**GraphQL :** `customerShipoutAvailableItems` ([Manuel GraphQL](/api/graphql/documentation#/storage-shipout/customerShipoutAvailableItems))

```graphql
query {
  customerShipoutAvailableItems(warehouse_id: 7) {
    result
    data {
      warehouses { id name available_count }
      storage_orders { id warehouse_id packages { id description weight length width height } }
    }
  }
}
```

**Vérification :** vous avez relevé un ou plusieurs `storage_package_ids` (exemple `5001`). Une liste vide signifie que rien n’est encore réceptionné — ne créez pas d’expédition. `403` signifie que l’expédition est désactivée pour ce client.

## 10. Estimer et créer l’expédition

Une expédition est tarifée par un service d’expédition de l’entreprise. Votre intégration liste les services disponibles depuis l’entrepôt, estime le prix pour la destination de l’acheteur, puis crée l’expédition pour les colis sélectionnés.

**REST :** `GET /api/v1/customer/shipout-orders/services?warehouse_id=7` — [Manuel REST](/api/documentation#/paths/v1-customer-shipout-orders-services/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/services?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "services": [
      { "id": 5, "service_code": "intl_express", "name": { "en": "Express" }, "pricing_method": 1, "pricing_method_name": "Shipping Price Plan", "support_multi_package": true }
    ]
  }
}
```

Relevez un `service_code`.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/estimate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "delivery_postcode": "K2P1L4",
    "delivery_country": "CA",
    "packages": [{
      "weight": 14,
      "length": 60,
      "width": 40,
      "height": 40,
      "weight_unit": 2,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "shipping_fee": 38.5,
    "fuel_surcharge": 4.2,
    "handling_fee": 0,
    "sub_total": 42.7,
    "tax": 5.55,
    "total": 48.25,
    "currency": "CAD",
    "has_items_needing_quote": false,
    "refused": false
  }
}
```

- `total` / `currency` — le prix estimé pour cette destination.
- `has_items_needing_quote` — `true` lorsque le service est tarifé manuellement ; l’entrepôt fixe le prix après la création de l’expédition, et le paiement attend ce prix.
- `refused` / `refusal_message` — le service n’acceptera pas cet envoi, car il ne peut pas le tarifer.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-order-NW-20931" \
  -d '{
    "warehouse_id": 7,
    "storage_package_ids": [5001],
    "delivery_name": "Maya Chen",
    "delivery_telephone": "6135550142",
    "delivery_email": "maya.chen@example.com",
    "delivery_address_1": "150 Elgin St",
    "delivery_city": "Ottawa",
    "delivery_province": "ON",
    "delivery_country": "CA",
    "delivery_postcode": "K2P1L4",
    "note": "Web order NW-20931"
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 0,
    "is_storage_shipout": true,
    "total_price": "48.25",
    "price_breakdown": { "total": 48.25, "has_items_needing_quote": false },
    "has_items_needing_quote": false,
    "storage_package_ids": [5001]
  }
}
```

- `data.id` — l’id de l’expédition. Conservez-le avec la commande de l’acheteur.
- `data.status` — `0` = en attente (paiement attendu), `1` = confirmée, `2` = en transit, `3` = expédiée, `4` = annulée, `5` = échouée.
- `storage_package_ids` — ces colis sont désormais verrouillés sur cette expédition et n’apparaissent plus à l’étape 9.
- Champs obligatoires : `warehouse_id`, `storage_package_ids`, `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`. Tous les colis doivent provenir du même entrepôt.

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

```graphql
mutation {
  customerCreateShipoutOrder(
    service_code: "intl_express"
    warehouse_id: 7
    storage_package_ids: [5001]
    delivery_name: "Maya Chen"
    delivery_telephone: "6135550142"
    delivery_email: "maya.chen@example.com"
    delivery_address_1: "150 Elgin St"
    delivery_city: "Ottawa"
    delivery_province: "ON"
    delivery_country: "CA"
    delivery_postcode: "K2P1L4"
    note: "Web order NW-20931"
  ) {
    result
    message
    data { id status total_price has_items_needing_quote storage_package_ids }
  }
}
```

**Vérification :** la réponse contient un `id` d’expédition. Les colis de stockage sélectionnés sont verrouillés sur cette demande.

## 11. Payer l’expédition

L’entrepôt traite une expédition une fois qu’elle est payée. Votre intégration paie le solde restant depuis le compte du client ; omettez `amount` pour le payer en totalité.

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

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

```json
{
  "result": true,
  "data": {
    "transaction_id": 9001,
    "amount": "48.25",
    "order_status": 1,
    "paid_amount": 48.25,
    "remaining_balance": 0
  }
}
```

- `amount` (requête, facultatif) — un montant partiel ; par défaut, la totalité du solde restant.
- `order_status` — `1` (confirmée) après le paiement complet.
- `remaining_balance` — `0` lorsque le paiement est complet.

**GraphQL :** `customerPayShipout` ([Manuel GraphQL](/api/graphql/documentation#/storage-shipout/customerPayShipout))

```graphql
mutation {
  customerPayShipout(id: 8001) {
    result
    message
    data { transaction_id amount order_status paid_amount remaining_balance }
  }
}
```

**Vérification :** le paiement enregistre un montant (ou `402` / `422` avec un motif clair). `402` signifie que le solde est insuffisant ; `422` signifie que la commande n’est pas encore payable (par exemple, elle attend encore un devis manuel) ou que le montant n’est pas valide.

## 12. Consulter et suivre l’expédition

Votre intégration consulte l’expédition pour suivre son statut et, une fois que l’entrepôt l’a expédiée, suit l’envoi par son numéro de suivi.

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

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

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 3,
    "status_label": "Shipped",
    "warehouse": { "id": 7, "name": "Toronto Hub" },
    "shipping_service": { "id": 5, "service_code": "intl_express" },
    "total_price": "48.25",
    "paid_amount": 48.25,
    "remaining_balance": 0,
    "can_be_paid": false,
    "can_be_cancelled": false,
    "storage_packages": [{ "id": 5001, "storage_order_id": 1024, "description": "Insulated jackets, carton of 12" }]
  }
}
```

- `status` / `status_label` — l’état actuel de l’expédition.
- `can_be_paid` / `can_be_cancelled` — indiquent si l’étape 11 ou l’étape 14 est actuellement autorisée.

Lorsqu’un numéro de suivi existe :

**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,
  "deliveried": false,
  "data": [
    { "tracking_event_status_id": 3, "description": "Package picked up", "location_city": "Toronto", "updated_at": "2026-12-02 14:30:00" }
  ]
}
```

- `data[]` — les événements de suivi dans l’ordre chronologique.
- `deliveried` — `true` dès que l’envoi est livré.

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

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

**Vérification :** la consultation de l’expédition renvoie le `status` attendu. Le suivi public trouve l’envoi dès qu’un numéro existe.

## 13. S’abonner aux webhooks

Les webhooks transmettent les changements de suivi et de statut à votre serveur, sans interrogation périodique. Un compte client définit ses propres URL de webhook et son secret de signature ; les paramètres sont enregistrés sur le compte client, et non sur l’entreprise.

**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://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status",
    "webhook_sign_secret": "nw-webhook-secret-2026-0123456789"
  }'
```

```json
{
  "result": true,
  "changed_keys": ["tracking_event_webhook_url", "order_status_change_webhook_url", "webhook_sign_secret"],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "************6789",
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status"
  }
}
```

- Seules les clés envoyées sont modifiées ; une clé inconnue ou une URL non valide répond `400`.
- `recipient_type` — `customer` confirme que les paramètres appartiennent au compte client.
- `webhook_sign_secret` — de 16 à 255 caractères ; conservez-le sur votre serveur pour vérifier les signatures.

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

Vérifiez **v2** : `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;
}
```

**Vérification :** la mise à jour renvoie `changed_keys` avec les clés envoyées, et un événement de test reçu à votre URL passe le contrôle de signature ci-dessus.

## 14. Annuler une expédition ou une commande de stockage

L’annulation libère ce qui était réservé. L’annulation d’une expédition remet ses colis en stock ; l’annulation d’une commande de stockage arrête une réservation dont les marchandises n’ont pas encore été réceptionnées.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/8001/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Buyer cancelled web order NW-20931"}'
```

```json
{ "result": true, "message": "Shipout cancelled." }
```

- `reason` (facultatif) — enregistré avec l’annulation.
- Une expédition ne peut être annulée que tant qu’elle est en attente (`0`) ou confirmée (`1`).

**GraphQL :** `customerCancelShipout` ([Manuel GraphQL](/api/graphql/documentation#/storage-shipout/customerCancelShipout))

```graphql
mutation {
  customerCancelShipout(id: 8001, reason: "Buyer cancelled web order NW-20931") {
    result
    message
  }
}
```

Cela libère le verrou des colis de stockage. Le stockage lui-même s’annule avec `POST /api/v1/customer/storage-orders/{id}/cancel` tant que c’est encore autorisé (statut `pending payment`, `confirmed`, `waiting for pickup` ou `awaiting dropoff` ; [Manuel REST](/api/documentation#/paths/v1-customer-storage-orders-id--cancel/post)).

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

```json
{ "result": true, "message": "Storage order cancelled", "data": { "id": 1024, "status": "cancelled" } }
```

- Le montant déjà payé pour la commande de stockage est recrédité sur le solde du client.
- Une commande de stockage dans tout autre statut répond `403`.

**Vérification :** `422` signifie que ce statut ne peut pas être annulé. Après une annulation d’expédition réussie, l’étape 9 liste à nouveau les colis.

## 15. Gestion des erreurs

| Situation | Statut HTTP | Code | Action de l’intégration |
|---|---|---|---|
| Jeton manquant ou expiré, ou mauvais type de compte | 401 | — | Reconnectez-vous en tant que client (étape 4). |
| Devis de stockage sans `warehouse_id` ni dates | 400 | — | Envoyez `warehouse_id`, `start_date` et `end_date`. |
| Échec de validation de la commande de stockage (dimensions d’article manquantes, `end_date` non postérieure à `start_date`, `packaging_id` manquant) | 422 | — | Lisez `errors`, corrigez les champs et renvoyez la requête. |
| Paiement du stockage avec un solde insuffisant, ou commande déjà entièrement payée | 400 | — | Rechargez le solde (la réponse contient `customer_balance`), ou arrêtez si la commande est déjà payée. |
| Paiement du stockage avec un `custom_amount` hors de la plage autorisée | 422 | — | Payez un montant compris entre le minimum et le solde restant. |
| La commande de stockage ne peut pas être annulée dans son statut actuel | 403 | — | Demandez à l’entrepôt de traiter la commande ; ne réessayez pas. |
| Expédition désactivée pour ce client | 403 | — | Demandez à l’entreprise d’activer l’expédition pour le compte client. |
| Code de service inconnu, ou expédition / commande de stockage introuvable | 404 | — | Relisez la liste des services ou vérifiez l’id conservé. |
| Colis non disponible, colis provenant d’entrepôts différents, ou service non proposé depuis l’entrepôt | 422 | — | Relisez l’étape 9 et sélectionnez des colis disponibles d’un seul entrepôt. |
| Le service d’expédition ne peut pas tarifer l’envoi et le refuse | 422 | `unpriced_refused` | Choisissez un autre service ou une autre destination ; rien n’a été créé. |
| Paiement de l’expédition avec un solde insuffisant | 402 | — | Rechargez le solde, puis réessayez l’étape 11. |
| Expédition pas encore payable (en attente d’un devis manuel) ou montant non valide | 422 | — | Attendez le prix, consultez à nouveau l’expédition, puis payez. |
| L’expédition ne peut pas être annulée dans son statut actuel | 422 | — | L’envoi est déjà en cours ; ne réessayez pas. |
| Même `Idempotency-Key` envoyé avec un corps différent | 409 | `IDEMPOTENCY_CONFLICT` | Utilisez une nouvelle clé pour une requête différente. |
| Requête d’origine avec le même `Idempotency-Key` encore en cours de traitement | 409 | `IDEMPOTENCY_IN_PROGRESS` | Attendez le nombre de secondes indiqué par `Retry-After` et renvoyez la même requête. |

## Liste de tests

- [ ] La configuration du stockage renvoie un `id` d’entrepôt.
- [ ] Le devis de stockage renvoie un prix, et la création du stockage renvoie `data.id`.
- [ ] Le paiement du stockage réussit, **ou** vous avez confirmé que le portefeuille doit être rechargé.
- [ ] La liste des articles disponibles contient les colis réceptionnés (`storage_package_ids`).
- [ ] L’estimation de l’expédition renvoie un prix ou `has_items_needing_quote`, et la création de l’expédition renvoie un `id` et verrouille ces colis.
- [ ] Le paiement de l’expédition réussit (ou `402` / `422` est compris).
- [ ] Le suivi public trouve l’envoi dès qu’un numéro de suivi existe.
- [ ] L’annulation de l’expédition libère les colis, **ou** ce statut ne peut pas être annulé.
- [ ] La répétition d’une création avec le même `Idempotency-Key` et le même corps renvoie `replayed: true` et aucune seconde commande.
- [ ] Les paramètres de webhook renvoient `recipient_type: customer`, et un événement reçu passe le contrôle de signature v2.
