# Commandes

Les commandes sont le cœur de la plateforme. Chaque commande :

* Appartient à exactement une boutique (scope par votre clé API — vous ne pouvez jamais toucher accidentellement aux données d'un autre marchand).
* A un cycle de vie en 7 états.
* Est liée à un client (dédupliqué par téléphone dans la boutique).
* A 1 ou plus articles, chacun pouvant porter des variantes. **Les add-ons produit (les extras payants à champs personnalisés) ne sont pas supportés par l'API v1** — voir [Add-ons](#add-ons-are-not-exposed).
* A un statut de paiement (`pending`, `paid`, `refunded`) indépendant du statut de fulfillment.

## Cycle de vie[​](#lifecycle "Lien direct vers Cycle de vie")

```
chemin principal  pending → confirmed → processing → shipped → delivered

sauts légaux      pending → processing        confirmed → shipped

annulation        pending | confirmed | processing → cancelled   (terminal)

retour            shipped | delivered → returned                 (terminal)
```

Voici la carte exacte des transitions appliquée par l'API, état par état :

| Depuis       | États suivants autorisés               |
| ------------ | -------------------------------------- |
| `pending`    | `confirmed`, `processing`, `cancelled` |
| `confirmed`  | `processing`, `shipped`, `cancelled`   |
| `processing` | `shipped`, `cancelled`                 |
| `shipped`    | `delivered`, `returned`                |
| `delivered`  | `returned`                             |
| `cancelled`  | — (terminal)                           |
| `returned`   | — (terminal)                           |

L'annulation n'est légale **que** depuis `pending`, `confirmed` et `processing` — une commande `shipped` ou `delivered` ne peut plus être annulée.

Un PATCH vers le statut que la commande porte déjà est un `200` sans effet.

| État         | Signification                                                  | Stock                                                                           |
| ------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `pending`    | Créée via vitrine ou API. En attente de confirmation marchand. | Non engagé, sauf si la boutique déduit le stock dès la réception de la commande |
| `confirmed`  | Marchand a confirmé (appel, revue panier).                     | **Engagé** (décrémenté)                                                         |
| `processing` | En préparation pour le transporteur.                           | Engagé                                                                          |
| `shipped`    | Remis au transporteur.                                         | Engagé                                                                          |
| `delivered`  | Client a signé.                                                | Engagé                                                                          |
| `cancelled`  | Annulée — stock restitué s'il avait été engagé.                | Restitué                                                                        |
| `returned`   | Retournée par le client — stock restitué.                      | Restitué                                                                        |

`cancelled` et `returned` sont **terminaux** — pas de transition de sortie.

## `POST /v1/orders` — créer une commande[​](#post-v1orders--créer-une-commande "Lien direct vers post-v1orders--créer-une-commande")

Créer une commande pour la boutique appelante. Utilisé par les **thèmes personnalisés**, les **apps mobiles / natives**, les **revendeurs**, et toute **vitrine headless** qui soumet ses commandes depuis son propre serveur au lieu d'utiliser le checkout intégré de la vitrine.

**Auth :** clé plateforme avec `orders:write` et `orders:read`. **Nécessite `Idempotency-Key`.** La réponse relit la commande : une clé qui n'a que `orders:write` enregistre la commande mais reçoit `403 forbidden`, et un nouvel essai avec la même `Idempotency-Key` renvoie ce même `403`.

La commande est créée en `pending`. Par défaut, le stock n'est **PAS** engagé à la création : c'est la première entrée dans un état engagé (`confirmed`, `processing`, `shipped` ou `delivered`) qui décrémente le stock, comme dans le flux du tableau de bord. Intentionnel : ça laisse à votre équipe le temps de filtrer les fakes / doublons / non-réponses avant de toucher à l'inventaire. Une boutique dont **Commandes → Paramètres d'affichage → Déduction du stock** est réglé sur **Dès la réception de la commande** voit son stock pris dès que l'API crée la commande.

### Corps[​](#corps "Lien direct vers Corps")

```
{

  "customer": {

    "name":      "Sarra Benali",

    "phone":     "0555000111",

    "email":     "sarra@example.com",

    "wilaya_id": 16,

    "commune":   "Bab Ezzouar",

    "address":   "12 Rue X, Apt 3"

  },

  "delivery": {

    "type":      "home",

    "desk_id":    null,

    "desk_name":  null

  },

  "items": [

    {

      "product_id": 26,

      "quantity":   2,

      "variants": [

        { "group_name": "Color", "option_name": "Red",  "color_code": "#ff0000", "price_adjustment": 0 },

        { "group_name": "Size",  "option_name": "L",    "color_code": null,      "price_adjustment": 200 }

      ]

    }

  ],

  "discount":       0,

  "payment_method": "cod",

  "notes":          "Please call before delivery"

}
```

### Référence des champs[​](#référence-des-champs "Lien direct vers Référence des champs")

#### `customer` (objet, requis)[​](#customer-objet-requis "Lien direct vers customer-objet-requis")

| Champ       | Type           | Requis | Notes                                                                                                                                      |
| ----------- | -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`      | string (1–255) | ✅     | Nom complet                                                                                                                                |
| `phone`     | string         | ✅     | `^\+?[0-9 ]{6,20}$` — algérien ou international                                                                                            |
| `email`     | string \| null |        | Si présent, sera enregistré sur la fiche client                                                                                            |
| `wilaya_id` | int            | ✅     | Code wilaya algérienne : de 1 à 58, ou de 1 à 69 sur une boutique réglée sur 69 wilayas (lisez `wilaya_mode` via `GET /v1/shipping/rates`) |
| `commune`   | string (1–100) | ✅     | Texte libre, ex. "Bab Ezzouar"                                                                                                             |
| `address`   | string         |        | Rue + appt ; peut être vide pour stop-desk                                                                                                 |

Les clients sont dédupliqués par boutique + téléphone. Si un client avec ce téléphone existe déjà dans votre boutique, sa fiche est **mise à jour** (nom, wilaya, commune, adresse, email) et réutilisée. Sinon, une nouvelle fiche est créée.

#### `delivery` (objet, optionnel)[​](#delivery-objet-optionnel "Lien direct vers delivery-objet-optionnel")

| Champ       | Type                                      | Défaut | Notes                                                                                                                                                                                                                                                                                                         |
| ----------- | ----------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`      | `home` \| `desk` \| `pickup` \| `digital` | `home` | `digital` pour produits téléchargeables uniquement. `pickup` et `digital` ne portent aucun frais de livraison. Si la boutique a désactivé le type choisi pour cette wilaya et activé l'autre, la commande peut passer au type que la boutique propose ; `delivery.type` dans la réponse indique le type final |
| `desk_id`   | int \| null                               | null   | Requis si `type = desk` et que vous voulez un bureau précis                                                                                                                                                                                                                                                   |
| `desk_name` | string \| null                            | null   | Libellé humain optionnel                                                                                                                                                                                                                                                                                      |

#### `items` (tableau, requis, 1–50 lignes)[​](#items-tableau-requis-150-lignes "Lien direct vers items-tableau-requis-150-lignes")

| Champ        | Type                      | Requis | Notes                                                                     |
| ------------ | ------------------------- | ------ | ------------------------------------------------------------------------- |
| `product_id` | int                       | ✅     | Doit appartenir à votre boutique (les IDs cross-store sont rejetés `400`) |
| `quantity`   | int, de 1 à 9999          |        | Vaut 1 si omis                                                            |
| `variants`   | tableau d'objets variante |        | Voir ci-dessous                                                           |

**Important — tarification autoritative côté serveur.** Vous ne précisez **pas** le prix de la ligne. C'est toujours le prix catalogue courant du produit qui est appliqué. Si vous envoyez un champ `price`, il est ignoré.

Le `price_adjustment` de chaque variante est lui aussi autoritatif côté serveur : pour chaque paire `(group_name, option_name)` qui correspond à une vraie option du produit, DZBuild substitue le `price_adjustment` du catalogue. Votre valeur ne survit que pour les paires absentes du catalogue — une tolérance volontaire pour les intégrations obsolètes — donc une « remise » négative que vous inventez est silencieusement écartée pour toute option réelle. Traitez `price_adjustment` en entrée comme purement informatif : renvoyez la valeur lue sur `GET /v1/products/{id}` pour que votre total côté client corresponde à celui du serveur.

#### `items[].variants` (tableau, optionnel)[​](#itemsvariants-tableau-optionnel "Lien direct vers itemsvariants-tableau-optionnel")

Chaque objet variante décrit un choix d'option pour un groupe de variantes du produit :

| Champ              | Type           | Notes                                                                                                                                                |
| ------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `group_name`       | string         | ex. "Color", "Size", "Material"                                                                                                                      |
| `option_name`      | string         | ex. "Red", "L", "Cotton"                                                                                                                             |
| `color_code`       | string \| null | Couleur hex (variantes de type couleur uniquement)                                                                                                   |
| `price_adjustment` | number         | Ajouté au prix de base. Écrasé par la valeur du catalogue dès que la paire groupe/option existe sur le produit — voir la note tarification ci-dessus |

Envoyez un objet variante par **groupe choisi** sur la ligne. Donc "T-shirt rouge taille L" devient 2 entrées (une pour Color/Red, une pour Size/L). DZBuild les rend sur la page commande du dashboard exactement comme si le client avait cliqué sur la vitrine.

Pour les produits utilisant des **variantes par pièce** (ex. offre "achetez 3 t-shirts, choisissez une couleur par pièce"), utilisez `quantity = 1` par ligne et créez une ligne par pièce — c'est le mapping le plus propre.

#### Champs monétaires de niveau supérieur[​](#champs-monétaires-de-niveau-supérieur "Lien direct vers Champs monétaires de niveau supérieur")

| Champ            | Type                                         | Défaut | Notes                                                                                                                                                                                                                                                                                                                                                                                  |
| ---------------- | -------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shipping_cost`  | number                                       |        | Ignoré s'il est envoyé. Le serveur calcule la livraison à partir du tarif de la boutique pour la wilaya et le type de livraison, de ses règles de livraison gratuite et du supplément de poids, comme pour une commande créée dans le tableau de bord. Les commandes `pickup` et `digital` ne portent aucun frais de livraison. Le montant facturé figure dans `amounts.shipping_cost` |
| `discount`       | number ≥ 0                                   | 0      | Montant code promo, remise manuelle, etc. Plafonné au sous-total plus la livraison                                                                                                                                                                                                                                                                                                     |
| `payment_fee`    | number                                       |        | Ignoré s'il est envoyé ; toujours 0                                                                                                                                                                                                                                                                                                                                                    |
| `payment_method` | `cod` \| `free_digital` \| `digital_payment` | auto   | Défaut `cod` pour physique, `free_digital` pour digital                                                                                                                                                                                                                                                                                                                                |
| `notes`          | string ≤ 1000                                | null   | Notes client, visibles sur la page commande du dashboard                                                                                                                                                                                                                                                                                                                               |

Le total est calculé côté serveur : `subtotal + shipping_cost - discount` (jamais sous 0), où `shipping_cost` est le montant calculé par le serveur lui-même. Le `subtotal` lui-même = `sum(items[].quantity × (price + Σ variants.price_adjustment))`.

Seul `discount` est repris de votre corps de requête. Il doit être ≥ 0, il est plafonné au sous-total plus la livraison, et il n'est **pas** vérifié contre les codes promo de votre boutique : `POST /v1/orders` ne doit donc être appelé que depuis un serveur de confiance, jamais depuis du code navigateur ou applicatif qu'un client peut altérer.

### Requête[​](#requête "Lien direct vers Requête")

```
curl -X POST 'https://api.dzbuild.app/v1/orders' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "customer": {

      "name":      "Sarra Benali",

      "phone":     "0555000111",

      "wilaya_id": 16,

      "commune":   "Bab Ezzouar",

      "address":   "12 Rue X"

    },

    "items": [

      { "product_id": 26, "quantity": 1,

        "variants": [

          { "group_name": "Duration", "option_name": "30 days", "price_adjustment": 0 }

        ]

      }

    ],

    "payment_method": "cod"

  }'
```

### Réponse 200[​](#réponse-200 "Lien direct vers Réponse 200")

La création d'une commande renvoie HTTP **200**, pas 201 — ne testez pas le code de statut ; vérifiez `data.id` / `data.order_number`. Le corps a la même forme que `GET /v1/orders/{id}` — entièrement peuplé avec totaux calculés, bloc client normalisé, et lignes que vous venez de créer (avec leurs variantes).

`order_number` a le format `ORD-{store_id}-{YYYYMMDD}-{8 caractères hex majuscules}`. Les commandes issues d'une landing page utilisent `LP-{8 caractères hex majuscules}`. Les commandes créées avant le 2026-06-02 portent un suffixe hérité de 4 caractères hex : un parseur doit accepter les deux longueurs.

### Erreurs[​](#erreurs "Lien direct vers Erreurs")

| Code                                                                            | Cause                                                                                                                                              |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bad_request` "Body must be valid JSON"                                         | JSON malformé, ou corps qui est une valeur JSON isolée comme une chaîne ou un nombre au lieu d'un objet (l'en-tête Content-Type n'est pas vérifié) |
| `bad_request` "customer object is required"                                     | `customer` manquant                                                                                                                                |
| `bad_request` "customer.name is required (1-255 chars)"                         | Nom manquant ou trop long                                                                                                                          |
| `bad_request` "customer.phone is required (digits, optional leading +)"         | Téléphone non conforme regex                                                                                                                       |
| `bad_request` "customer.wilaya\_id must be 1-58"                                | Wilaya hors de la plage de la boutique ; une boutique réglée sur 69 wilayas répond "customer.wilaya\_id must be 1-69"                              |
| `bad_request` "customer.commune is required (1-100 chars)"                      | Commune manquante ou trop longue                                                                                                                   |
| `bad_request` "items must be a non-empty array"                                 | Panier vide                                                                                                                                        |
| `bad_request` "items: max 50 lines per order"                                   | Trop de lignes (splitter en plusieurs commandes)                                                                                                   |
| `bad_request` "items\[N].product\_id is required"                               | `product_id` manquant                                                                                                                              |
| `bad_request` "Product N does not belong to this store"                         | ID cross-boutique                                                                                                                                  |
| `bad_request` "items\[N].quantity must be 1-9999"                               | Quantité invalide                                                                                                                                  |
| `bad_request` "delivery.type must be home, desk, pickup, or digital"            | Type de livraison invalide                                                                                                                         |
| `bad_request` "payment\_method must be cod, free\_digital, or digital\_payment" | Méthode de paiement invalide                                                                                                                       |
| `bad_request` "discount must be >= 0"                                           | Remise négative                                                                                                                                    |
| `bad_request` "Monthly order limit reached for this store plan"                 | Plafond du plan **Free** — voir ci-dessous                                                                                                         |

**Plafond mensuel de commandes.** Le plan **Free** est plafonné à 30 commandes par mois calendaire ; Pro, Unlimited et Enterprise sont sans plafond. Un nom de plan non reconnu retombe également sur le plafond Free de 30/mois. Le compteur est mensuel calendaire et couvre **toutes** les sources de commandes (vitrine + landing page + tableau de bord + API).

### Idempotence[​](#idempotence "Lien direct vers Idempotence")

Chaque POST doit porter un en-tête `Idempotency-Key`. Si vous rejouez la même requête (même `Idempotency-Key`, même clé API, mêmes octets de corps) sous 24 h, on renvoie la **même** réponse avec `Idempotency-Replay: 1` : la commande est créée exactement une fois. Les réponses d'erreur sont rejouées aussi, sauf `429` et `5xx`, et la même `Idempotency-Key` avec un corps différent répond `422 idempotency_key_reuse` : envoyez une commande corrigée avec une nouvelle clé. Voir [Idempotence](https://dzbuild.com/fr/fr/api-docs/idempotency.md).

```
# rejouable indéfiniment avec la même clé

KEY="$(uuidgen)"

for i in 1 2 3; do

  curl -X POST 'https://api.dzbuild.app/v1/orders' \

    -H "Authorization: Bearer $DZ_KEY" \

    -H "Content-Type: application/json" \

    -H "Idempotency-Key: $KEY" \

    -d @order.json

done

# une seule commande est créée
```

### Webhook déclenché[​](#webhook-déclenché "Lien direct vers Webhook déclenché")

Créer une commande déclenche `order.created` vers tous les abonnements `/v1/webhooks` à cet événement. Voir [Événements webhook](https://dzbuild.com/fr/fr/api-docs/webhooks/events.md).

`/v1/webhooks` ne voit que l'activité API

Les abonnements `/v1/webhooks` se déclenchent **uniquement** pour les changements effectués via l'API v1. Les commandes passées sur la vitrine ou une landing page, et les changements de statut faits dans le tableau de bord, ne déclenchent **pas** `order.created` / `order.confirmed` / `order.shipped`.

Pour des événements couvrant *toute* l'activité commandes, utilisez l'**add-on Webhooks** (`/dashboard/addons` → « Webhooks — Connect n8n, Make & Zapier », **nécessite le plan Unlimited**), qui capte toutes les commandes quelle que soit leur origine — ou continuez à interroger `GET /v1/orders?since=…`.

### Ce que le chemin de commande API saute[​](#ce-que-le-chemin-de-commande-api-saute "Lien direct vers Ce que le chemin de commande API saute")

Une commande créée via l'API est un insert allégé. Comparée à une commande vitrine, landing page ou tableau de bord, elle **ne** :

* Produit aucun événement Pixel ou CAPI Meta / TikTok.
* N'incrémente pas les compteurs `total_orders` / `total_spent` du client.
* Ne consulte pas la blacklist client — la commande API d'un client blacklisté passe. Lisez `is_banned` depuis [Clients](https://dzbuild.com/fr/fr/api-docs/resources/customers.md) et refusez côté client si nécessaire.
* N'applique ni les contrôles anti-abus automatiques du checkout de la vitrine, ni les règles de l'add-on quantité min/max.

Le marchand reçoit bien la notification habituelle de nouvelle commande, la même que pour une commande vitrine.

Notez aussi qu'un client *créé* par l'API stocke la chaîne `customer.name` entière dans `first_name` et laisse `last_name` vide ; pour un client existant retrouvé par téléphone, seul `first_name` est écrasé et tout `last_name` existant reste intact.

***

## `GET /v1/orders`[​](#get-v1orders "Lien direct vers get-v1orders")

Liste les commandes. Pagination par curseur.

**Auth :** clé avec `orders:read` (incluse dans les scopes par défaut). Une clé sans ce scope reçoit `403 forbidden` « Missing scope: orders<!-- -->:read<!-- --> ».

### Paramètres de requête[​](#paramètres-de-requête "Lien direct vers Paramètres de requête")

| Param            | Type           | Notes                 |
| ---------------- | -------------- | --------------------- |
| `limit`          | int 1–200      | Défaut 50             |
| `cursor`         | string         | Opaque                |
| `status`         | un des 7 états | Filtre                |
| `since`          | ISO 8601       | `created_at >= since` |
| `customer_phone` | string         | Match exact           |

Un `status` invalide ou un `since` non analysable est silencieusement ignoré — vous recevez la liste non filtrée, pas un `400`. Il n'y a pas de filtre `payment_status` en v1 ; filtrez côté client.

### Requête[​](#requête-1 "Lien direct vers Requête")

```
curl 'https://api.dzbuild.app/v1/orders?status=pending&limit=20' \

  -H "Authorization: Bearer $DZ_KEY"
```

### Réponse 200[​](#réponse-200-1 "Lien direct vers Réponse 200")

```
{

  "data": {

    "items": [

      {

        "id":              6894,

        "order_number":    "ORD-13-20260317-AD3C91F7",

        "status":          "pending",

        "payment_status":  "pending",

        "payment_method":  "cod",

        "total":           1000,

        "customer_name":   "John Doe",

        "customer_phone":  "0555000000",

        "wilaya_id":       16,

        "commune":         "Bab Ezzouar",

        "delivery_type":   "home",

        "created_at":      "2026-03-17 15:18:13"

      }

    ],

    "next_cursor": null,

    "has_more": false

  }

}
```

La vue liste est volontairement compacte (pas d'items, pas de variantes). Appelez `GET /v1/orders/{id}` pour le détail complet.

***

## `GET /v1/orders/{id}`[​](#get-v1ordersid "Lien direct vers get-v1ordersid")

Détail complet avec lignes, variantes et bloc client.

**Auth :** clé avec `orders:read`. Une clé sans ce scope reçoit `403 forbidden`.

### Réponse 200[​](#réponse-200-2 "Lien direct vers Réponse 200")

```
{

  "data": {

    "id":             6894,

    "order_number":   "ORD-13-20260317-AD3C91F7",

    "store_seq":      644,

    "status":         "pending",

    "payment_status": "pending",

    "payment_method": "cod",

    "customer": {

      "id":         5578,

      "name":       "John Doe",

      "phone":      "0555000000",

      "email":      null,

      "wilaya_id":  16,

      "commune":    "Bab Ezzouar",

      "address":    "12 Rue X"

    },

    "delivery": { "type": "home", "desk_id": null, "desk_name": null },

    "shipment": {

      "sent_to_delivery":    true,

      "sent_to_delivery_at": "2026-09-06 16:04:30",

      "delivery_company":    "colivraison",

      "delivery_tracking":   "TRK-0000000000",

      "last_send_failure":   null

    },

    "amounts": {

      "subtotal":      1000,

      "shipping_cost": 0,

      "discount":      0,

      "payment_fee":   0,

      "total":         1000

    },

    "items": [

      {

        "id":         8421,

        "product_id": 26,

        "price":      1000,

        "quantity":   1,

        "variants": [

          { "order_item_id": 8421, "group_name": "Duration", "option_name": "30 days",

            "color_code": null, "price_adjustment": "0.00" }

        ]

      }

    ],

    "created_at": "2026-03-17 15:18:13",

    "updated_at": "2026-03-17 15:18:13"

  }

}
```

### Champs d'expédition[​](#champs-dexpédition "Lien direct vers Champs d'expédition")

`store_seq` est le numéro affiché dans le tableau de bord (`#644`) ; une création via l'API numérote la commande avant de répondre, et dans le rare cas où la numérotation échoue il vaut null jusqu'à ce qu'il soit rempli peu après. `shipment.sent_to_delivery` passe à true quand le transporteur a accepté le colis ; `delivery_company` est le transporteur (son slug) et `delivery_tracking` son numéro de suivi. `shipment.last_send_failure` est le dernier envoi refusé pour cette commande (`at`, `provider`, `message` du transporteur), ou null si aucun envoi n'a échoué. Il vaut toujours null dès que `sent_to_delivery` est true : un échec antérieur n'apparaît plus après un envoi réussi. La création, la mise à jour et l'annulation renvoient le même objet.

### Variantes dans la réponse[​](#variantes-dans-la-réponse "Lien direct vers Variantes dans la réponse")

Le tableau `variants` de chaque article est la source de vérité de ce que le client a choisi. Chaque entrée a `order_item_id`, `group_name`, `option_name`, `color_code` (variantes couleur), et `price_adjustment` (le supplément par pièce, renvoyé comme **string** JSON et non comme nombre). Pour les offres multi-pièces, vous verrez plusieurs entrées de variantes sur le même article avec des regroupements effectifs différents — voir notes par pièce ci-dessous.

### Ce que la réponse détail ne renvoie pas[​](#ce-que-la-réponse-détail-ne-renvoie-pas "Lien direct vers Ce que la réponse détail ne renvoie pas")

* `notes` est en **écriture seule** en v1 — il est stocké sur la commande et visible dans le tableau de bord, mais jamais renvoyé par `GET /v1/orders/{id}`.
* Les `product_name`, `sku` de l'article et le `total` de la ligne sont capturés à la création mais ne sont pas renvoyés non plus. Rejoignez `GET /v1/products/{id}` si vous avez besoin des noms.

### Les add-ons ne sont pas exposés[​](#add-ons-are-not-exposed "Lien direct vers Les add-ons ne sont pas exposés")

Les add-ons produit (les extras payants à champs personnalisés qu'un marchand configure sur un produit) n'ont aucune surface en v1 : vous ne pouvez pas les envoyer sur `POST /v1/orders`, et `GET /v1/orders/{id}` ne renvoie aucune entrée d'add-on. Une commande passée sur la vitrine **avec** des add-ons se relit via l'API avec le montant des add-ons déjà intégré dans `items[].price` (et donc dans `amounts.subtotal`) — mais sans ligne d'add-on, sans titre et sans attribution de fichier.

***

## `PATCH /v1/orders/{id}` — changer le statut[​](#patch-v1ordersid--changer-le-statut "Lien direct vers patch-v1ordersid--changer-le-statut")

**Auth :** clé plateforme avec `orders:write` et `orders:read`. **Nécessite `Idempotency-Key`.** Sans `orders:read`, le statut change quand même, mais la réponse est `403 forbidden`.

Le corps doit être `{"status": "<un des 7>"}`. On valide la transition contre le tableau du [Cycle de vie](#lifecycle) ; sinon on renvoie `400` avec les états suivants légaux. Un PATCH vers le statut courant de la commande est un `200` sans effet.

### Requête[​](#requête-2 "Lien direct vers Requête")

```
curl -X PATCH 'https://api.dzbuild.app/v1/orders/6894' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: confirm-6894-$(date +%s)" \

  -d '{"status": "confirmed"}'
```

Renvoie `200` et le détail commande à jour. Les transitions concurrentes sur une même commande sont sérialisées : si une autre requête a changé le statut avant vous, la vôtre échoue proprement en `bad_request` (« order status changed concurrently; retry ») au lieu de s'appliquer deux fois.

### Effets sur le stock[​](#effets-sur-le-stock "Lien direct vers Effets sur le stock")

Les états à **stock engagé** sont `confirmed`, `processing`, `shipped` et `delivered`.

* **Non-engagé → engagé** — le stock est **décrémenté** la première fois que la commande entre dans l'un de ces états, y compris via le raccourci `pending → processing`. Le compteur de ventes du produit augmente également.
* Sur une boutique dont **Déduction du stock** est réglé sur **Dès la réception de la commande**, le stock a déjà été pris à la création de la commande : ce passage ne prend rien de plus.
* **Engagé → cancelled / returned** — le stock est **restitué**.
* Les autres transitions ne touchent pas au stock.

Le stock n'est **pas validé** : la décrémentation est bornée à zéro, donc une survente n'est jamais rejetée, et un problème de stock ne fait jamais échouer ni annuler le changement de statut. Le stock se comporte exactement comme pour les commandes gérées depuis le tableau de bord.

### Erreurs[​](#erreurs-1 "Lien direct vers Erreurs")

| Code                                                                          | Cause                                                                                                                                                                                                                          |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `bad_request` "Field "status" is required"                                    | Le corps n'a pas de `status`                                                                                                                                                                                                   |
| `bad_request` "status must be one of: pending, confirmed, …"                  | String invalide                                                                                                                                                                                                                |
| `bad_request` "Transition X -> Y not allowed. From 'X' you can only go to: …" | Non autorisé par la machine à états (l'API émet un `->` ASCII simple)                                                                                                                                                          |
| `bad_request` "order status changed concurrently; retry"                      | Une autre requête a changé le statut pendant votre transition. Réessayez avec une **nouvelle** `Idempotency-Key` : ce 400 est enregistré sous la clé envoyée, et un nouvel essai avec elle rejoue la même erreur pendant 24 h. |
| `not_found`                                                                   | ID commande inconnu ou appartient à une autre boutique                                                                                                                                                                         |

***

## `POST /v1/orders/{id}/cancel`[​](#post-v1ordersidcancel "Lien direct vers post-v1ordersidcancel")

Endpoint de commodité — **exactement** équivalent à `PATCH /v1/orders/{id}` avec `{"status":"cancelled"}`. Même table de transitions, aucune permission supplémentaire. L'annulation n'est légale que depuis `pending`, `confirmed` et `processing` ; depuis `shipped` ou `delivered` vous obtenez `400 bad_request` (ces états ne peuvent aller que vers `delivered` / `returned`).

**Auth :** clé plateforme avec `orders:write` et `orders:read`. **Nécessite `Idempotency-Key`.** Sans `orders:read`, la commande est quand même annulée, mais la réponse est `403 forbidden`.

```
curl -X POST 'https://api.dzbuild.app/v1/orders/6894/cancel' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: cancel-6894-$(date +%s)"
```

***

## `POST /v1/orders/{id}/send-to-delivery`[​](#post-v1ordersidsend-to-delivery "Lien direct vers post-v1ordersidsend-to-delivery")

Remet la commande au transporteur de la boutique, le même envoi que le bouton du tableau de bord. Un colis ne peut pas être rappelé une fois chez le transporteur : chaque envoi demande donc deux appels, et le marchand approuve le premier.

**Auth :** clé avec `delivery:send`. **Nécessite `Idempotency-Key`.** Les clés créées dans **Paramètres → API** ne portent pas ce scope et reçoivent `403 forbidden` ; une application l'obtient quand le marchand l'approuve à l'installation.

| Champ           | Type              | Notes                                                                                                                                             |
| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider`      | string, optionnel | Slug d'un transporteur lié à la boutique. Omettez-le pour utiliser le transporteur par défaut de la boutique. Un transporteur non lié est refusé. |
| `confirm_token` | string            | Le jeton du premier appel, envoyé une fois que le marchand a approuvé                                                                             |

1. Appelez sans `confirm_token`. La réponse est `422 confirmation_required` avec `confirm_token` (usage unique, valable `600` secondes), `action` et `will_change` : client, téléphone, destination, type de livraison, total et transporteur. Montrez ce résumé au marchand.
2. Une fois qu'il approuve, refaites l'appel avec le même `provider`, le `confirm_token` et une **nouvelle** `Idempotency-Key` (la première clé est liée au corps sans le jeton). Un jeton expiré, déjà utilisé ou qui ne correspond plus à la commande répond `422 confirmation_stale` avec un nouveau jeton. Un drapeau `confirm: true` n'est pas accepté ici.

L'envoi tourne normalement en arrière-plan et répond `202` avec `queued: true`. Lisez `GET /v1/orders/{id}` jusqu'à ce que `shipment.sent_to_delivery` soit true avec un `delivery_tracking`, ou que `shipment.last_send_failure` montre le refus du transporteur. Si le traitement en arrière-plan n'est pas disponible, l'envoi se fait dans la requête : `200` avec `sent`, `provider` et `tracking`, ou `422 courier_refused` avec le message du transporteur. Une fois le colis accepté par le transporteur, une commande `pending` ou `confirmed` passe en `processing`, ce qui prend le stock s'il ne l'était pas encore. Les commandes de type de livraison `pickup` ne sont jamais remises à un transporteur. Un envoi en arrière-plan arrêté avant l'appel au transporteur, par exemple pour une commande `pickup` ou un transporteur non lié, répond quand même `202` et ne change aucun des deux champs `shipment`.

| Code                                                | Cause                                                                                                                                         |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `forbidden` (403)                                   | La clé n'a pas `delivery:send`                                                                                                                |
| `not_found` (404)                                   | Commande inconnue, ou commande d'une autre boutique                                                                                           |
| `already_sent` (409)                                | La commande est déjà chez un transporteur ; `error.tracking` contient son numéro de suivi                                                     |
| `confirmation_required`, `confirmation_stale` (422) | Voir les deux étapes ci-dessus                                                                                                                |
| `courier_refused` (422)                             | Le transporteur a refusé le colis pendant un envoi dans la requête                                                                            |
| `rate_limited`, `too_many_concurrent` (429)         | Les appels transporteur ont leur propre budget par boutique, en plus des [limites de taux](https://dzbuild.com/fr/fr/api-docs/rate-limits.md) |

```
curl -X POST 'https://api.dzbuild.app/v1/orders/6894/send-to-delivery' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: send-6894-approved" \

  -d '{"confirm_token": "cft_REPLACE_WITH_TOKEN"}'
```

***

## Variantes — référence complète[​](#variantes--référence-complète "Lien direct vers Variantes — référence complète")

Les variantes font qu'un seul produit couvre plusieurs options (couleur, taille, matière, capacité, …). Sur la vitrine, les clients cliquent sur des cartes de variantes pour choisir ; via l'API, **vous** envoyez les options choisies dans la commande.

### Modèle de variantes[​](#modèle-de-variantes "Lien direct vers Modèle de variantes")

Un produit a 0 ou + **groupes de variantes**. Un groupe a 0 ou + **options**. Chaque option peut avoir :

* Une `value` (nom affiché)
* Un `color_code` (hex, variantes couleur uniquement)
* Un `price_adjustment` (ajouté au prix de base)
* Un `image_id` (image produit liée comme swatch)

Lisez `GET /v1/products/{id}` pour voir tous les groupes et options d'un produit :

```
{

  "variants": [

    {

      "id":   11,

      "name": "Color",

      "type": "color",

      "options": [

        { "id": 14, "value": "Red",  "color_code": "#ff0000", "image_id": 28, "stock": 12 },

        { "id": 15, "value": "Blue", "color_code": "#0000ff", "image_id": 29, "stock": 5  }

      ]

    },

    {

      "id":   12,

      "name": "Size",

      "type": "text",

      "options": [

        { "id": 16, "value": "S", "stock": 10 },

        { "id": 17, "value": "M", "stock": 10 },

        { "id": 18, "value": "L", "stock": 5  }

      ]

    }

  ]

}
```

Un produit avec 2 couleurs × 3 tailles a 6 **combinaisons**.

### Comment envoyer les variantes sur `POST /v1/orders`[​](#comment-envoyer-les-variantes-sur-post-v1orders "Lien direct vers comment-envoyer-les-variantes-sur-post-v1orders")

Convertissez la sélection client en une entrée variante par groupe choisi. Pour "T-shirt rouge taille L" :

```
"variants": [

  { "group_name": "Color", "option_name": "Red", "color_code": "#ff0000", "price_adjustment": 0 },

  { "group_name": "Size",  "option_name": "L",   "color_code": null,      "price_adjustment": 0 }

]
```

Les noms envoyés sont stockés tels quels sur la commande — ils doivent matcher ce qu'a renvoyé `GET /v1/products/{id}`. `price_adjustment` est ajouté au prix de la ligne (donc `final_price = product.price + Σ price_adjustment`), mais pour toute paire présente dans le catalogue le serveur substitue sa propre valeur : envoyez donc le chiffre du catalogue.

### Stock par variante[​](#stock-par-variante "Lien direct vers Stock par variante")

Si le marchand active `variant_stock_enabled` sur un produit, chaque **option** de variante porte son propre compteur de stock. Lisez-le depuis `options[].stock`. L'API ne vous bloque pas pour créer une commande avec `quantity > stock` ; c'est l'arbitrage du marchand. Le stock est décrémenté à la première entrée dans un état engagé (`confirmed`, `processing`, `shipped`, `delivered`), ou dès la création sur une boutique qui déduit le stock dès la réception de la commande, et n'est jamais validé : la décrémentation est bornée à zéro, donc une survente est enregistrée plutôt que rejetée.

### Stock par combinaison[​](#stock-par-combinaison "Lien direct vers Stock par combinaison")

Si `combination_stock_enabled` est on, le stock est suivi par **combinaison** (Red+L = 5, Red+M = 8, etc.). `GET /v1/products/{id}` les renvoie dans `combinations[]` (chacune avec `id`, `sku`, `stock`, `is_active` et ses `options`), avec `combination_count` ; la liste s'arrête à 300 entrées et `combinations_truncated` indique si elle a été coupée. Voir [Produits](https://dzbuild.com/fr/fr/api-docs/resources/products.md). Le stock par combinaison bouge au même moment que le reste du stock de la commande.

### Variantes en cascade[​](#variantes-en-cascade "Lien direct vers Variantes en cascade")

L'add-on Cascading Variants permet au marchand de faire dépendre les options du groupe B de la sélection du groupe A (ex. "Marque → Modèle" — choisir "Apple" pour Marque ne montre que "iPhone 15" / "iPhone 14" pour Modèle). Cet add-on nécessite le plan Pro.

`GET /v1/products/{id}` n'expose **aucune** information de cascade — le mappage parent/enfant n'est appliqué que sur la vitrine et dans le tableau de bord. Votre client ne peut pas découvrir les règles de cascade via l'API en v1 : codez-les en dur, ou lisez-les depuis le tableau de bord. Envoyer une combinaison invalide crée quand même la commande (on ne bloque pas) — mais le marchand la rejettera au confirm.

### Variantes image-texte[​](#variantes-image-texte "Lien direct vers Variantes image-texte")

Certains marchands utilisent le type `image_text` (vignette à côté du libellé). Sur l'API, vous envoyez toujours `group_name` + `option_name` — l'image est purement une affaire de vitrine et n'est pas dans la payload de commande.

### Offres multi-pièces[​](#offres-multi-pièces "Lien direct vers Offres multi-pièces")

Si le marchand fait une offre "Achetez 3, mixez les couleurs", le client choisit une variante différente par pièce. Sur l'API :

```
"items": [

  { "product_id": 26, "quantity": 1,

    "variants": [{ "group_name": "Color", "option_name": "Red" }] },

  { "product_id": 26, "quantity": 1,

    "variants": [{ "group_name": "Color", "option_name": "Blue" }] },

  { "product_id": 26, "quantity": 1,

    "variants": [{ "group_name": "Color", "option_name": "Green" }] }

]
```

Trois lignes séparées, chacune `quantity = 1`. Ainsi la page commande dashboard affiche la couleur de chaque pièce proprement.

***

## Patterns courants[​](#patterns-courants "Lien direct vers Patterns courants")

### « Soumettre une commande depuis une vitrine React custom »[​](#-soumettre-une-commande-depuis-une-vitrine-react-custom- "Lien direct vers « Soumettre une commande depuis une vitrine React custom »")

Vous construisez une vitrine React/Vue/Next.js qui parle à l'API au lieu d'utiliser les thèmes intégrés DZBuild. Flow :

1. Lisez `GET /v1/products` + `GET /v1/products/{id}` pour rendre le catalogue.
2. L'utilisateur ajoute au panier côté client.
3. Pour afficher un prix de livraison avant la validation, lisez les tarifs de la boutique avec `GET /v1/shipping/rates` (scope `shipping:read`). La commande elle-même est toujours facturée au montant calculé par le serveur.
4. POST `/v1/orders` avec panier + bloc client ; `amounts.shipping_cost` dans la réponse est le coût de livraison facturé.
5. Affichez au client son `order_number` et une page « merci ».
6. Interrogez `GET /v1/orders?since=…` pour suivre l'avancement du fulfillment. Les événements `/v1/webhooks` `order.confirmed` / `order.shipped` ne se déclenchent que si le statut a été changé **via l'API** — un marchand qui confirme dans le tableau de bord ne produit aucun événement.

Voir le guide [Thèmes & vitrines personnalisés](https://dzbuild.com/fr/fr/api-docs/guides/custom-storefronts.md) pour un walkthrough complet.

### « Synchroniser les nouvelles commandes vers mon CRM chaque minute »[​](#-synchroniser-les-nouvelles-commandes-vers-mon-crm-chaque-minute- "Lien direct vers « Synchroniser les nouvelles commandes vers mon CRM chaque minute »")

Utilisez le filtre `since` :

```
curl 'https://api.dzbuild.app/v1/orders?since=2026-04-30T20:00:00Z&limit=200' \

  -H "Authorization: Bearer $DZ_KEY"
```

Le polling est le bon outil ici. Un abonnement `/v1/webhooks` sur `order.created` ne couvre que les commandes créées par votre propre intégration via l'API — les commandes vitrine et landing page ne le déclenchent jamais, il ne peut donc pas remplacer le balayage `since`. Voir [Webhooks](https://dzbuild.com/fr/fr/api-docs/webhooks/overview.md).

### « Confirmer toutes les commandes pending d'un seul client »[​](#-confirmer-toutes-les-commandes-pending-dun-seul-client- "Lien direct vers « Confirmer toutes les commandes pending d'un seul client »")

```
PHONE="0555000000"

curl -sS "https://api.dzbuild.app/v1/orders?status=pending&customer_phone=$PHONE" \

  -H "Authorization: Bearer $DZ_KEY" \

| jq -r '.data.items[].id' \

| while read OID; do

    curl -sS -X PATCH "https://api.dzbuild.app/v1/orders/$OID" \

      -H "Authorization: Bearer $DZ_KEY" \

      -H "Content-Type: application/json" \

      -H "Idempotency-Key: confirm-$OID" \

      -d '{"status":"confirmed"}'

  done
```
