# Produits

Le produit est l'unité vendable de base d'une boutique. Tous les appels produit sont scopés à la boutique de la clé appelante — vous ne pouvez jamais toucher accidentellement les données d'un autre marchand.

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

Liste les produits. Pagination par curseur. Servi à neuf à chaque appel : un `GET` envoyé juste après une écriture renvoie les nouvelles valeurs.

**Auth :** clé plateforme avec `products:read` (scope accordé par défaut). Une clé qui ne l'a pas reçoit `403 forbidden`.

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

| Param    | Type                              | Défaut | Notes                                     |
| -------- | --------------------------------- | ------ | ----------------------------------------- |
| `limit`  | int (1–200)                       | 50     | Taille de page                            |
| `cursor` | string                            | —      | Du `next_cursor` d'une réponse précédente |
| `status` | `active` \| `draft` \| `archived` | —      | Filtre par statut                         |
| `search` | string                            | —      | Match sur `name` (LIKE) et `sku` exact    |

Un `status` non reconnu est ignoré plutôt que rejeté — vous recevez la liste non filtrée, qui **inclut les produits `archived`**. Filtrez explicitement si vous ne voulez que les articles en ligne.

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

```
curl 'https://api.dzbuild.app/v1/products?limit=10&status=active' \

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

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

```
{

  "data": {

    "items": [

      {

        "id":             26,

        "name":           "PRO",

        "slug":           "pro",

        "short_description": null,

        "price":          1000,

        "compare_price":  null,

        "sku":            "",

        "stock_quantity": 0,

        "track_stock":    false,

        "status":         "active",

        "has_variants":   true,

        "featured":       false,

        "primary_image":  "https://cdn.dzbuild.app/uploads/products/123/123_1700000000_example.webp",

        "created_at":     "2026-01-13 15:06:06",

        "updated_at":     "2026-01-13 15:12:32"

      }

    ],

    "next_cursor": null,

    "has_more":    false

  },

  "meta": { "request_id": "...", "api_version": "v1" }

}
```

Changement en v1.1 — les URL d'images sont désormais complètes

`primary_image` (ainsi que `images[].url` sur `GET /v1/products/{id}`) est maintenant une **URL CDN complète**, utilisable telle quelle. Avant la v1.1, les deux renvoyaient un nom de fichier nu que l'appelant devait préfixer lui-même. Si votre intégration construit ce préfixe manuellement, supprimez cette logique — la valeur commence déjà par `https://`.

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

Détail complet du produit incluant images et variantes.

**Auth :** clé plateforme avec `products:read`.

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

```
curl https://api.dzbuild.app/v1/products/26 \

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

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

```
{

  "data": {

    "id":               26,

    "name":             "PRO",

    "slug":             "pro",

    "description":      "- Single store\n- Up to 300 products\n- ...",

    "short_description": null,

    "category_id":      null,

    "pricing": {

      "price":         1000,

      "compare_price": null,

      "cost_price":    null

    },

    "inventory": {

      "sku":             "",

      "barcode":         null,

      "track_stock":     false,

      "stock_quantity":  0,

      "low_stock_alert": 5

    },

    "shipping": {

      "weight": null, "height": null, "width": null, "length": null,

      "do_insurance": false

    },

    "status":       "active",

    "featured":     false,

    "has_variants": true,

    "images": [

      { "id": 28, "url": "https://cdn.dzbuild.app/uploads/products/123/123_1700000000_example.webp",

        "alt_text": "Front view", "is_primary": true, "sort_order": 0 }

    ],

    "variants": [

      {

        "id":   11,

        "name": "Duration",

        "type": "text",

        "required": true,

        "sort_order": 0,

        "options": [

          { "id": 14, "value": "30 days", "color_code": null, "price_adjustment": 0,

            "stock": null, "sku": null, "image_id": null, "show_as_card": false,

            "sort_order": 0, "is_active": true },

          { "id": 15, "value": "90 days", "color_code": null, "price_adjustment": 500,

            "stock": null, "sku": null, "image_id": null, "show_as_card": false,

            "sort_order": 1, "is_active": true }

        ]

      }

    ],

    "combinations": [],

    "combination_count": 0,

    "combinations_truncated": false,

    "created_at": "2026-01-13 15:06:06",

    "updated_at": "2026-01-13 15:12:32"

  }

}
```

Ajouté en v1.1

`images[].alt_text`, les champs d'option complets (`price_adjustment`, `sku`, `show_as_card`, `sort_order`, `is_active`), les `required` / `sort_order` du groupe, ainsi que tout le bloc `combinations` sont nouveaux. `combinations` liste au maximum 300 entrées — `combination_count` donne toujours le total réel et `combinations_truncated` vous indique quand la liste a été tronquée.

## `POST /v1/products` — créer[​](#post-v1products--créer "Lien direct vers post-v1products--créer")

**Auth :** clé plateforme avec `products:write` et `products:read`. La réponse est le produit tel que le renvoie `GET /v1/products/{id}` : une clé sans `products:read` reçoit donc `403 forbidden` alors que le produit a bien été créé. **Nécessite `Idempotency-Key`.**

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

| Champ                                  | Type                              | Requis | Notes                                                                                                                                                                                                                                                                    |
| -------------------------------------- | --------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`                                 | string (1–255)                    | ✅     |                                                                                                                                                                                                                                                                          |
| `price`                                | number ≥ 0                        | ✅     | DZD                                                                                                                                                                                                                                                                      |
| `compare_price`                        | number ≥ 0 \| null                |        | Prix barré                                                                                                                                                                                                                                                               |
| `cost_price`                           | number ≥ 0 \| null                |        | Interne — jamais montré au client                                                                                                                                                                                                                                        |
| `description`                          | string                            |        | Long format, retours à la ligne et mise en forme HTML acceptés (gras, listes, titres, liens, tableaux, images) ; les scripts et autres balises dangereuses sont retirés. Maximum 60000 octets : au-delà, la réponse est `bad_request` "description exceeds 60000 bytes". |
| `short_description`                    | string ≤ 500                      |        | Phrase courte                                                                                                                                                                                                                                                            |
| `sku`                                  | string ≤ 100                      |        | SKU interne                                                                                                                                                                                                                                                              |
| `barcode`                              | string ≤ 100                      |        | UPC/EAN                                                                                                                                                                                                                                                                  |
| `weight`                               | number                            |        | kg, pour la livraison                                                                                                                                                                                                                                                    |
| `shipping_height` / `width` / `length` | number                            |        | cm                                                                                                                                                                                                                                                                       |
| `do_insurance`                         | bool                              |        | Forcer l'assurance livraison sur ce produit                                                                                                                                                                                                                              |
| `track_stock`                          | bool                              |        | Défaut `false`                                                                                                                                                                                                                                                           |
| `stock_quantity`                       | int ≥ 0                           |        | Si `track_stock`                                                                                                                                                                                                                                                         |
| `low_stock_alert`                      | int ≥ 0                           |        | Défaut `5`. Alimente le badge « stock bas » du tableau de bord.                                                                                                                                                                                                          |
| `variant_stock_enabled`                | bool                              |        | Suivi du stock par **option** de variante (Rouge, L, …)                                                                                                                                                                                                                  |
| `combination_stock_enabled`            | bool                              |        | Suivi du stock par **combinaison** de variantes (Rouge+L). Implique `variant_stock_enabled`.                                                                                                                                                                             |
| `category_id`                          | int                               |        | Doit exister dans votre boutique. Le produit rejoint cette catégorie, qui devient sa catégorie principale ; les catégories auxquelles il appartient déjà sont conservées. En PATCH, `null` efface la catégorie principale sans retirer le produit d'aucune catégorie.    |
| `status`                               | `active` \| `draft` \| `archived` |        | Défaut `draft`                                                                                                                                                                                                                                                           |
| `featured`                             | bool                              |        | Défaut `false`                                                                                                                                                                                                                                                           |

Quand `variant_stock_enabled` ou `combination_stock_enabled` vaut true, `track_stock` est désactivé automatiquement (les variantes gèrent leur propre stock).

Vous avez rarement besoin de ces deux drapeaux directement : `PUT /v1/products/{id}/variants` les positionne pour vous en fonction de la charge utile envoyée (stock par option ou combinaisons).

### Limite par plan[​](#limite-par-plan "Lien direct vers Limite par plan")

Free : 5 produits actifs. Pro : 300. Unlimited / Enterprise : illimité. Le contrôle ne compte que les produits en `status: "active"`, les brouillons ne comptent pas, et le comptage est toujours effectué en direct au moment de l'appel. Il ne s'exécute qu'à la **création** : faire passer un brouillon existant à `active` via PATCH n'est jamais bloqué, donc une boutique en plan Free peut dépasser 5 produits actifs par ce biais. Comme il s'exécute à chaque création, une boutique à sa limite ne peut pas créer de nouveau produit, même avec `status: "draft"`. Un nom de plan non reconnu retombe sur la limite Free de 5. À la limite :

```
{ "error": { "code": "bad_request",

             "message": "Plan 'free' allows at most 5 active products. Upgrade to add more." } }
```

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

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

  -H "Authorization: Bearer $DZ_KEY" \

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

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

  -d '{

    "name":           "T-shirt - Cotton 200gsm",

    "price":          1500,

    "compare_price":  1900,

    "description":    "100% cotton, made in Algeria.",

    "sku":            "TS-COT-200",

    "stock_quantity": 50,

    "track_stock":    true,

    "status":         "draft"

  }'
```

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

Une création réussie renvoie HTTP **200** (et non 201) avec le même corps que `GET /v1/products/{id}`. Ne testez pas `status === 201` — vérifiez `data.id` à la place. `id`, `slug` et `created_at` sont maintenant remplis.

À la création, le `slug` est toujours dérivé de `name` — un `slug` envoyé dans le corps est **ignoré**. Pour fixer un slug précis, créez d'abord, puis `PATCH /v1/products/{id}` avec `{"slug":"…"}`. La normalisation passe en minuscules et remplace chaque suite de caractères non alphanumériques par `-` (compatible Unicode — les lettres arabes et accentuées sont conservées, ce n'est donc PAS `[a-z0-9-]`), avec troncature à 200 caractères ; les collisions reçoivent les suffixes `-2`, `-3`, …

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

| Code                                                         | Cause                                   |
| ------------------------------------------------------------ | --------------------------------------- |
| `bad_request` "Body must be valid JSON"                      | Content-Type incorrect ou JSON malformé |
| `bad_request` "name is required (1-255 chars)"               | Nom manquant ou trop long               |
| `bad_request` "price must be a non-negative number"          | Prix invalide                           |
| `bad_request` "category\_id N does not belong to this store" | ID de catégorie cross-store             |
| `bad_request` "Plan 'free' allows at most …"                 | Limite de plan                          |

## `PATCH /v1/products/{id}` — mettre à jour[​](#patch-v1productsid--mettre-à-jour "Lien direct vers patch-v1productsid--mettre-à-jour")

**Auth :** clé plateforme avec `products:write` et `products:read`. La réponse est le produit mis à jour : une clé sans `products:read` reçoit donc `403 forbidden` alors que la modification a bien été enregistrée. **Nécessite `Idempotency-Key`.**

Mise à jour partielle — n'envoyez que les champs à changer. Les champs non spécifiés sont préservés.

```
curl -X PATCH 'https://api.dzbuild.app/v1/products/26' \

  -H "Authorization: Bearer $DZ_KEY" \

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

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

  -d '{ "price": 1200, "status": "active" }'
```

Renvoie `200` et le produit complet mis à jour. Si le produit n'existe pas (ou appartient à une autre boutique), vous obtenez `404 not_found`.

Renommer via `PATCH { name: ... }` régénère automatiquement le `slug` uniquement si vous n'avez pas envoyé `slug` explicitement. Envoyez `slug` pour préserver une URL spécifique après un renommage.

## `DELETE /v1/products/{id}`[​](#delete-v1productsid "Lien direct vers delete-v1productsid")

**Auth :** clé plateforme avec `products:write`. **Nécessite `Idempotency-Key`.**

```
curl -X DELETE 'https://api.dzbuild.app/v1/products/26' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: del-26-2026-04-30"
```

Réponse :

```
{ "data": { "deleted": true, "id": 26 } }
```

C'est une **suppression dure** : le produit est supprimé avec ses images, variantes, offres, add-ons, combinaisons et avis clients. Les **fichiers** images stockés ne sont pas supprimés par cet appel : une URL d'image enregistrée auparavant peut donc continuer à s'afficher.

La suppression détache l'historique ; un produit utilisé par une landing page ne peut pas être supprimé

Les commandes passées conservent leurs lignes, et le nom, le SKU et le prix du produit capturés au moment de l'achat restent intacts, donc les anciennes commandes restent lisibles, mais la ligne ne pointe plus vers un produit (`product_id` devient `null`). Un produit utilisé par une landing page (au niveau de la page, ou dans une section formulaire de commande, bouton de commande ou offres produit) est refusé avec `409 product_in_use_by_landing_page` ; les données de l'erreur listent les pages dans `landing_pages[]` avec `id`, `title` et `slug`. Supprimez d'abord cette landing page (`DELETE /v1/landing-pages/{id}`) ou associez-lui un autre produit (`PATCH /v1/landing-pages/{id}` avec un nouveau `product_id`), puis supprimez le produit. Préférez `PATCH { "status": "archived" }` à la suppression.

## `POST /v1/products/{id}/images` — ajouter une image[​](#post-v1productsidimages--ajouter-une-image "Lien direct vers post-v1productsidimages--ajouter-une-image")

*Ajouté en v1.1.* **Auth :** clé plateforme avec `products:write`. **Nécessite `Idempotency-Key`.**

Vous fournissez une **URL `https` publique** ; DZBuild télécharge l'image côté serveur, la convertit, l'optimise et l'héberge sur le CDN de la boutique. Il n'y a pas d'upload de fichier via l'API — donnez le lien de l'image et nous allons la chercher.

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

| Champ        | Type          | Requis | Notes                                               |
| ------------ | ------------- | ------ | --------------------------------------------------- |
| `url`        | string ≤ 2000 | ✅     | Lien `https://` public vers le fichier image        |
| `alt_text`   | string ≤ 255  |        | Texte d'accessibilité / SEO                         |
| `is_primary` | bool          |        | Faire de cette image la photo principale du produit |

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/images' \

  -H "Authorization: Bearer $DZ_KEY" \

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

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

  -d '{ "url": "https://example.com/tshirt-front.jpg", "alt_text": "T-shirt front" }'
```

```
{ "data": { "image": { "id": 88,

                       "url": "https://cdn.dzbuild.app/uploads/products/123/123_1700000001_example.webp",

                       "alt_text": "T-shirt front", "is_primary": true, "sort_order": 0,

                       "file_size": 27652, "width": 1000, "height": 1000 },

            "deduplicated": false } }
```

Règles à connaître :

* **La première image d'un produit devient automatiquement l'image principale.**
* Envoyer une URL dont les octets sont déjà attachés au produit ne crée **pas** de doublon — vous récupérez l'image existante avec `"deduplicated": true` (HTTP 200 au lieu de 201).
* Formats acceptés : JPEG, PNG, WebP, GIF, BMP, AVIF, HEIC/HEIF, TIFF. Maximum **20 Mo** et **10000×10000 px**. Les images sont converties en WebP (EXIF supprimé), et une image de plus de 2000 px de large est réduite à 2000 px de large en gardant ses proportions. Un fichier WebP de 3 Mo ou moins et d'au plus 2000 px de large est stocké tel quel.
* Maximum **20 images par produit**.

### Quelles URL sont acceptées[​](#quelles-url-sont-acceptées "Lien direct vers Quelles URL sont acceptées")

Pour des raisons de sécurité, le téléchargeur n'accepte que les adresses **publiques** et ne suit jamais les redirections. Une URL est refusée (`url_refused`) lorsqu'elle n'est pas en `https`, qu'elle porte des identifiants (`https://user:pass@…`), qu'elle utilise un port autre que 443, qu'elle est une adresse IP plutôt qu'un nom d'hôte, ou qu'elle résout vers une adresse privée / interne / de métadonnées cloud. Un lien qui répond par une redirection ou un statut d'erreur échoue avec `image_fetch_failed` ; un lien qui répond par une page web (une page de connexion, par exemple) ou tout autre fichier qui n'est pas une image prise en charge échoue avec `unsupported_image`.

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

| Code                 | HTTP | Cause                                                                                                       |
| -------------------- | ---- | ----------------------------------------------------------------------------------------------------------- |
| `validation_error`   | 422  | `url` manquant ou de plus de 2000 caractères                                                                |
| `url_refused`        | 422  | URL rejetée par les règles ci-dessus                                                                        |
| `image_fetch_failed` | 422  | Hôte injoignable, redirection ou réponse non-200                                                            |
| `unsupported_image`  | 422  | Contenu qui n'est pas une image (une page web, par exemple), format non supporté ou dimensions hors limites |
| `image_too_large`    | 422  | Au-delà de 20 Mo                                                                                            |
| `too_many_images`    | 422  | Le produit a déjà 20 images                                                                                 |
| `not_found`          | 404  | Produit absent de votre boutique                                                                            |

## `PATCH /v1/products/{id}/images/{image_id}`[​](#patch-v1productsidimagesimage_id "Lien direct vers patch-v1productsidimagesimage_id")

*Ajouté en v1.1.* Met à jour `alt_text`, `sort_order` (0–999), ou promeut l'image avec `is_primary: true`.

```
curl -X PATCH 'https://api.dzbuild.app/v1/products/26/images/88' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

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

  -d '{ "is_primary": true }'
```

Un produit conserve toujours exactement une image principale : `is_primary: false` est donc rejeté avec `primary_required` — promouvez une autre image à la place.

## `DELETE /v1/products/{id}/images/{image_id}`[​](#delete-v1productsidimagesimage_id "Lien direct vers delete-v1productsidimagesimage_id")

*Ajouté en v1.1.* Supprime la ligne image et les fichiers stockés associés.

```
{ "data": { "deleted": true, "new_primary_image_id": 89,

            "variant_references_cleared": 2, "remaining_images": 3 } }
```

Si des options de variantes pointaient vers cette image, ces liens sont effacés (les options elles-mêmes survivent) — `variant_references_cleared` vous indique combien. Supprimer l'image principale promeut automatiquement la suivante.

## `PUT /v1/products/{id}/variants` — remplacer les variantes[​](#put-v1productsidvariants--remplacer-les-variantes "Lien direct vers put-v1productsidvariants--remplacer-les-variantes")

*Ajouté en v1.1.* **Auth :** clé plateforme avec `products:write`. `Idempotency-Key` est facultatif : avec cet en-tête, une nouvelle tentative avec la même valeur renvoie la première réponse ; sans lui, chaque appel refait le remplacement complet.

Ceci remplace TOUTES les variantes du produit

Il n'existe pas de mise à jour partielle des variantes. Lisez l'état actuel avec `GET /v1/products/{id}` et renvoyez **tout ce que vous voulez conserver** — tout ce qui est omis est supprimé. Envoyez `{"groups": []}` pour supprimer toutes les variantes.

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

| Champ                                 | Type                                                            | Requis | Notes                                                                                                                                               |
| ------------------------------------- | --------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `groups`                              | array                                                           | ✅     | Groupes de variantes dans l'ordre d'affichage. `[]` supprime toutes les variantes.                                                                  |
| `groups[].name`                       | string ≤ 100                                                    | ✅     | ex. `Color`, `Size`. Unique par produit.                                                                                                            |
| `groups[].type`                       | `text` \| `color` \| `image_text` \| `selectable` \| `dropdown` |        | Défaut `text`. `selectable` = groupe d'options additionnelles à sélection multiple. `dropdown` = options texte affichées dans une liste déroulante. |
| `groups[].required`                   | bool                                                            |        | Défaut `true` (toujours `false` pour `selectable`)                                                                                                  |
| `groups[].options[].name`             | string ≤ 100                                                    | ✅     | Unique à l'intérieur du groupe                                                                                                                      |
| `groups[].options[].color_code`       | `#rrggbb`                                                       |        | Pour les groupes `color`                                                                                                                            |
| `groups[].options[].price_adjustment` | number                                                          |        | Ajouté au (ou retranché du) prix de base                                                                                                            |
| `groups[].options[].stock`            | int ≥ 0 \| null                                                 |        | Stock par option                                                                                                                                    |
| `groups[].options[].sku`              | string ≤ 100                                                    |        | SKU par option                                                                                                                                      |
| `groups[].options[].image_id`         | int                                                             |        | Doit être une image existante **de ce produit**                                                                                                     |
| `groups[].options[].show_as_card`     | bool                                                            |        | Afficher l'option sous forme de carte image                                                                                                         |
| `combinations`                        | array                                                           |        | Stock par combinaison (nécessite au moins 2 groupes non-`selectable`)                                                                               |
| `combinations[].options`              | object                                                          | ✅     | `{ "Color": "Red", "Size": "L" }` — une entrée par groupe non-`selectable`                                                                          |
| `combinations[].stock`                | int ≥ 0                                                         | ✅     |                                                                                                                                                     |
| `combinations[].sku`                  | string ≤ 100                                                    |        |                                                                                                                                                     |
| `combinations[].is_active`            | bool                                                            |        | Défaut `true`                                                                                                                                       |

Limites : 10 groupes, 100 options par groupe, 200 options au total, 1000 combinaisons.

```
curl -X PUT 'https://api.dzbuild.app/v1/products/26/variants' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

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

  -d '{

    "groups": [

      { "name": "Color", "type": "color", "options": [

          { "name": "Red",  "color_code": "#ff0000", "image_id": 88 },

          { "name": "Blue", "color_code": "#0000ff" } ] },

      { "name": "Size", "type": "text", "options": [

          { "name": "L" }, { "name": "XL", "price_adjustment": 100 } ] }

    ],

    "combinations": [

      { "options": { "Color": "Red",  "Size": "L"  }, "stock": 5, "sku": "TS-R-L" },

      { "options": { "Color": "Blue", "Size": "XL" }, "stock": 2 }

    ]

  }'
```

Renvoie le nouveau bloc `variants` + `combinations` (même forme que `GET /v1/products/{id}`).

### Le mode de stock est réglé pour vous[​](#le-mode-de-stock-est-réglé-pour-vous "Lien direct vers Le mode de stock est réglé pour vous")

* **Combinaisons envoyées** → stock par combinaison (`combination_stock_enabled`), `track_stock` au niveau produit désactivé.
* **Pas de combinaisons, mais des options portant un `stock`** → stock par option (`variant_stock_enabled`), `track_stock` désactivé.
* **Ni l'un ni l'autre** → les variantes sont purement visuelles ; le stock au niveau produit continue de fonctionner.

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

| Code                          | HTTP | Cause                                                              |
| ----------------------------- | ---- | ------------------------------------------------------------------ |
| `validation_error`            | 422  | Noms, types, couleurs ou nombres invalides, ou une limite dépassée |
| `invalid_image_id`            | 422  | `image_id` n'est pas une image de ce produit                       |
| `combinations_not_applicable` | 422  | Combinaisons envoyées avec moins de 2 groupes non-`selectable`     |
| `duplicate_combination`       | 422  | Deux combinaisons avec le même jeu d'options                       |
| `not_found`                   | 404  | Produit absent de votre boutique                                   |

La validation s'exécute **avant** toute suppression — une charge utile rejetée laisse vos variantes existantes intactes.

## `GET /v1/products/{id}/stock`[​](#get-v1productsidstock "Lien direct vers get-v1productsidstock")

Lit le mode de stock du produit et la quantité actuelle de chaque cible modifiable dans ce mode. Lisez-le avant une synchronisation de stock pour obtenir les id des options et des combinaisons. Contrairement à `GET /v1/products`, cette lecture n'est pas mise en cache : elle montre une modification tout de suite.

**Auth :** clé plateforme avec `products:read`.

```
curl https://api.dzbuild.app/v1/products/30/stock \

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

```
{

  "data": {

    "product_id": 30,

    "mode": "variant_options",

    "track_stock": false,

    "flags": { "variant_stock_enabled": true, "combination_stock_enabled": false },

    "max_stock": 9999999,

    "options": [

      { "target": "option", "id": 41, "group": "Size", "value": "M",

        "stock": null, "unlimited": true },

      { "target": "option", "id": 42, "group": "Size", "value": "L",

        "stock": 10, "unlimited": false }

    ]

  }

}
```

`mode` indique où le stock du produit est compté, et la réponse liste les cibles de ce mode :

| `mode`            | Stock compté sur             | Listé dans la réponse                                                                                                                      |
| ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `product`         | Le produit lui-même          | `product.stock_quantity`                                                                                                                   |
| `variant_options` | Chaque option de variante    | `options[]`                                                                                                                                |
| `combinations`    | Chaque combinaison d'options | `combinations[]` avec `sku`, `is_active` et `options` (nom du groupe vers nom de l'option). `options[]` est aussi listé, en lecture seule. |

Une option avec `"stock": null` et `"unlimited": true` a un stock illimité. Le mode suit les variantes enregistrées avec `PUT /v1/products/{id}/variants` (voir plus haut).

## `POST /v1/products/{id}/stock`[​](#post-v1productsidstock "Lien direct vers post-v1productsidstock")

Fixe ou ajuste les quantités en stock. **Auth :** clé plateforme avec `products:write`. **Nécessite `Idempotency-Key`.**

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

`items` liste de 1 à 500 cibles. Chaque élément porte un seul de `set`, `delta` ou `"unlimited": true`, et une cible n'apparaît qu'une fois par requête.

| Champ               | Type                                   | Requis                            | Notes                                                                                                                                                                        |
| ------------------- | -------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `items[].target`    | `product` \| `option` \| `combination` | ✅                                | Doit correspondre au `mode` du produit : `product`, `option` pour `variant_options`, `combination` pour `combinations`                                                       |
| `items[].id`        | int ≥ 1                                | ✅ pour `option` et `combination` | L'id renvoyé par `GET /v1/products/{id}/stock`                                                                                                                               |
| `items[].set`       | int, de 0 à 9999999                    |                                   | Nouvelle quantité                                                                                                                                                            |
| `items[].delta`     | int, pas 0                             |                                   | Unités à ajouter, négatif pour en retirer. Le résultat reste entre 0 et 9999999.                                                                                             |
| `items[].unlimited` | bool                                   |                                   | Options uniquement. `true` rend le stock de l'option illimité. Une option illimitée aujourd'hui a besoin de `"unlimited": false` à côté de `set` pour commencer le décompte. |

Un `set` ou un `delta` sur la cible `product` active aussi `track_stock` : la boutique compte alors le stock de ce produit à partir de ce moment.

```
curl -X POST 'https://api.dzbuild.app/v1/products/30/stock' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

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

  -d '{

    "items": [

      { "target": "option", "id": 41, "set": 20, "unlimited": false },

      { "target": "option", "id": 42, "delta": -2 }

    ]

  }'
```

Renvoie `200` avec le stock après la modification, dans la forme du GET ci-dessus : l'option 41 affiche maintenant `20` et l'option 42 affiche `8`. Les éléments sont enregistrés ensemble : si l'un est refusé, aucun n'est enregistré. La modification entre dans l'historique des modifications de la boutique (`GET /v1/changes`), et `POST /v1/changes/{id}/undo` remet les quantités précédentes.

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

| Code                     | HTTP | Cause                                                                                                                                                                                                                                       |
| ------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`       | 422  | `items` absent, vide ou de plus de 500 éléments ; un `target`, `id`, `set` ou `delta` invalide ; la même cible deux fois ; pas exactement un de `set`, `delta`, `"unlimited": true` ; `"unlimited": true` sur un produit ou une combinaison |
| `option_stock_unlimited` | 422  | L'option est illimitée aujourd'hui : un `set` sans `"unlimited": false`, ou un `delta` quel qu'il soit                                                                                                                                      |
| `stock_mode_mismatch`    | 409  | `target` ne correspond pas au mode de stock du produit                                                                                                                                                                                      |
| `not_found`              | 404  | Produit absent de votre boutique, ou option ou combinaison qui n'appartient pas à ce produit                                                                                                                                                |

## `GET /v1/products/{id}/offers`[​](#get-v1productsidoffers "Lien direct vers get-v1productsidoffers")

Lit les offres de quantité du produit, les lots affichés sur la page produit.

**Auth :** clé plateforme avec `products:read`.

```
curl https://api.dzbuild.app/v1/products/26/offers \

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

```
{

  "data": {

    "product_id": 26,

    "pricing_note": "price is the TOTAL for the whole bundle of `quantity` units, not a unit price.",

    "offers": [

      { "id": 51, "title": "Buy 2, get 1 free", "quantity": 3, "price": 2000,

        "compare_price": 3000, "discount_type": null, "discount_value": null,

        "badge_text": "Best value", "badge_color": "#10b981", "free_shipping": true,

        "image_path": null, "image_url": null, "sort_order": 0, "is_active": true },

      { "id": 52, "title": "Pack of 2", "quantity": 2, "price": 0,

        "compare_price": null, "discount_type": "percent", "discount_value": 10,

        "badge_text": null, "badge_color": "#10b981", "free_shipping": false,

        "image_path": null, "image_url": null, "sort_order": 1, "is_active": true }

    ]

  }

}
```

`price` est le total du lot

`price` est ce que l'acheteur paie pour toutes les unités de `quantity` ensemble, pas un prix unitaire. Sur un produit à 1000 DZD, « 2 achetés, le 3e offert » s'écrit `"quantity": 3, "price": 2000`. Une offre avec un `discount_type` enregistre `price` à `0` et retire `discount_value` du prix du produit multiplié par la quantité : `amount` retire un montant en DZD, `percent` retire un pourcentage et l'applique aussi aux ajustements de prix des variantes. La deuxième offre ci-dessus vend 2 unités pour 1800 DZD.

`image_url` est le lien CDN complet de l'image de l'offre, `null` si elle n'en a pas.

## `POST /v1/products/{id}/offers`[​](#post-v1productsidoffers "Lien direct vers post-v1productsidoffers")

Remplace les offres de quantité du produit. **Auth :** clé plateforme avec `products:write`. **Nécessite `Idempotency-Key`.**

Ceci remplace TOUTES les offres du produit

Envoyez chaque offre à conserver, dans l'ordre d'affichage. Tout ce qui est omis est supprimé. Envoyez `{"offers": []}` pour supprimer toutes les offres.

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

| Champ                     | Type                          | Requis                  | Notes                                                                     |
| ------------------------- | ----------------------------- | ----------------------- | ------------------------------------------------------------------------- |
| `offers`                  | array (0 à 50)                | ✅                      | Offres dans l'ordre d'affichage. `[]` supprime toutes les offres.         |
| `offers[].title`          | string                        | ✅                      | Coupé à 255 caractères                                                    |
| `offers[].quantity`       | int, de 1 à 9999              | ✅                      | Unités dans le lot. Chaque offre a besoin d'une quantité différente.      |
| `offers[].price`          | number > 0                    | ✅ sans `discount_type` | Total du lot entier, en DZD. Ignoré quand `discount_type` est défini.     |
| `offers[].discount_type`  | `amount` \| `percent` \| null |                         | Défaut `null` (prix fixe du lot)                                          |
| `offers[].discount_value` | number > 0                    | ✅ avec `discount_type` | En DZD pour `amount`, 100 au maximum pour `percent`                       |
| `offers[].compare_price`  | number ≥ 0 \| null            |                         | Prix barré, en DZD                                                        |
| `offers[].badge_text`     | string                        |                         | Coupé à 100 caractères                                                    |
| `offers[].badge_color`    | `#rgb` ou `#rrggbb`           |                         | Défaut `#10b981`                                                          |
| `offers[].free_shipping`  | bool                          |                         | Livraison gratuite quand l'acheteur commande cette offre. Défaut `false`. |
| `offers[].image_path`     | string                        |                         | Garde l'image de l'offre : renvoyez le `image_path` donné par le GET      |
| `offers[].is_active`      | bool                          |                         | Défaut `true`                                                             |

Les images d'offre ne peuvent pas être envoyées par l'API : ajoutez-les depuis le tableau de bord. Une image dont vous omettez le `image_path` est supprimée.

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/offers' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

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

  -d '{

    "offers": [

      { "title": "Buy 2, get 1 free", "quantity": 3, "price": 2000,

        "compare_price": 3000, "badge_text": "Best value", "free_shipping": true },

      { "title": "Pack of 2", "quantity": 2, "discount_type": "percent", "discount_value": 10 }

    ]

  }'
```

Renvoie `200` avec les nouvelles offres, dans la forme du GET ci-dessus. Une charge utile rejetée laisse vos offres existantes intactes.

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

| Code                       | HTTP | Cause                                                                                                                                                                             |
| -------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`         | 422  | `offers` absent ou pas une liste, plus de 50 offres, un `title` manquant, ou un `quantity`, `price`, `discount_type`, `discount_value`, `compare_price` ou `badge_color` invalide |
| `duplicate_offer_quantity` | 422  | Deux offres avec la même `quantity`                                                                                                                                               |
| `invalid_image_path`       | 422  | `image_path` n'est pas une image déjà utilisée par les offres de ce produit                                                                                                       |
| `not_found`                | 404  | Produit absent de votre boutique                                                                                                                                                  |

## `GET /v1/products/{id}/addons`[​](#get-v1productsidaddons "Lien direct vers get-v1productsidaddons")

Lit les champs remplis par l'acheteur de ce produit : des champs supplémentaires que l'acheteur remplit sur la page produit (un texte court, un texte long ou l'envoi d'une image), et l'interrupteur `enabled` qui les affiche ou les masque.

**Auth :** clé plateforme avec `products:read`.

```
curl https://api.dzbuild.app/v1/products/26/addons \

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

```
{

  "data": {

    "product_id": 26,

    "enabled": true,

    "addons": [

      { "id": 7, "title": "Name to print", "input_type": "text",

        "placeholder": "Up to 20 letters", "is_required": true, "extra_price": 300,

        "max_length": 20, "allowed_extensions": null, "sort_order": 0, "is_active": true },

      { "id": 8, "title": "Your photo", "input_type": "image",

        "placeholder": null, "is_required": false, "extra_price": 0,

        "max_length": null, "allowed_extensions": "jpg,png", "sort_order": 1, "is_active": true }

    ]

  }

}
```

La page produit n'affiche les champs que tant que `enabled` vaut `true`, et seulement ceux qui ont `"is_active": true`. `extra_price` s'ajoute à la commande quand l'acheteur remplit ce champ.

## `POST /v1/products/{id}/addons`[​](#post-v1productsidaddons "Lien direct vers post-v1productsidaddons")

Remplace les champs remplis par l'acheteur et peut régler l'interrupteur `enabled` dans le même appel. **Auth :** clé plateforme avec `products:write`. **Nécessite `Idempotency-Key`.**

Ceci remplace TOUS les champs du produit

Envoyez chaque champ à conserver, dans l'ordre d'affichage. Tout ce qui est omis est supprimé. Envoyez `{"addons": []}` pour supprimer tous les champs.

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

| Champ                         | Type                                     | Requis | Notes                                                                                                             |
| ----------------------------- | ---------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------- |
| `enabled`                     | bool \| null                             |        | `true` affiche les champs sur la page produit, `false` les masque. Omis ou `null`, l'interrupteur reste tel quel. |
| `addons`                      | array (0 à 20)                           | ✅     | Champs dans l'ordre d'affichage. `[]` supprime tous les champs.                                                   |
| `addons[].title`              | string                                   | ✅     | Coupé à 255 caractères                                                                                            |
| `addons[].input_type`         | `text` \| `textarea` \| `image`          |        | Défaut `text`. `textarea` est un texte long, `image` l'envoi d'une image.                                         |
| `addons[].placeholder`        | string                                   |        | Coupé à 255 caractères                                                                                            |
| `addons[].is_required`        | bool                                     |        | Défaut `false`                                                                                                    |
| `addons[].extra_price`        | number ≥ 0                               |        | Montant en DZD ajouté quand l'acheteur remplit le champ. Défaut `0`.                                              |
| `addons[].max_length`         | int ≥ 1                                  |        | Champs `text` et `textarea` uniquement. Plafonné à 65535.                                                         |
| `addons[].allowed_extensions` | liste ou chaîne séparée par des virgules |        | Champs `image` uniquement : parmi `jpg`, `jpeg`, `png`, `gif`, `webp`. Défaut les cinq.                           |
| `addons[].is_active`          | bool                                     |        | Défaut `true`                                                                                                     |

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/addons' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

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

  -d '{

    "enabled": true,

    "addons": [

      { "title": "Name to print", "placeholder": "Up to 20 letters",

        "is_required": true, "extra_price": 300, "max_length": 20 },

      { "title": "Your photo", "input_type": "image", "allowed_extensions": ["jpg", "png"] }

    ]

  }'
```

Renvoie `200` avec les nouveaux champs et l'interrupteur, dans la forme du GET ci-dessus. Une charge utile rejetée laisse vos champs existants intacts.

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

| Code               | HTTP | Cause                                                                                                                                                                                                                                                              |
| ------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `validation_error` | 422  | `addons` absent ou pas une liste, plus de 20 champs, un `title` manquant, un `input_type` inconnu, un `extra_price` ou un `max_length` invalide, un `max_length` sur un champ `image`, `allowed_extensions` sur un autre type de champ ou avec une autre extension |
| `not_found`        | 404  | Produit absent de votre boutique                                                                                                                                                                                                                                   |

## `GET /v1/products/{id}/quantity-rules`[​](#get-v1productsidquantity-rules "Lien direct vers get-v1productsidquantity-rules")

Lit la quantité minimum et maximum de ce produit dans une seule commande. `0` signifie sans limite.

**Auth :** clé plateforme avec `products:read`.

```
curl https://api.dzbuild.app/v1/products/26/quantity-rules \

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

```
{

  "data": {

    "product_id": 26,

    "min_qty": 2,

    "max_qty": 10,

    "has_rule": true,

    "addon_id": "min-max-quantity",

    "addon_active": false,

    "warning": "The \"min-max-quantity\" addon is not active for this store, so this rule is stored but NOT enforced at checkout. Activate it from the dashboard Addons page."

  }

}
```

La règle n'est appliquée à la commande que tant que l'add-on [Quantité minimum & maximum par produit](https://dzbuild.com/fr/fr/docs/addons/min-max-quantity.md) est actif sur la boutique. L'add-on est disponible sur tous les plans. `addon_active` indique s'il est actif, et `warning` apparaît quand une règle est enregistrée alors que l'add-on est désactivé.

## `POST /v1/products/{id}/quantity-rules`[​](#post-v1productsidquantity-rules "Lien direct vers post-v1productsidquantity-rules")

Fixe la quantité de commande minimum et maximum du produit. **Auth :** clé plateforme avec `products:write`. **Nécessite `Idempotency-Key`.**

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

| Champ     | Type              | Requis | Notes                                                                                                      |
| --------- | ----------------- | ------ | ---------------------------------------------------------------------------------------------------------- |
| `min_qty` | int, de 0 à 10000 |        | `0` = pas de minimum. Omis, il compte comme `0`.                                                           |
| `max_qty` | int, de 0 à 10000 |        | `0` = pas de maximum. Omis, il compte comme `0`. Au-dessus de `0`, il doit être au moins égal à `min_qty`. |

Chaque appel écrit les deux valeurs, alors envoyez-les ensemble : un champ omis devient `0`. Envoyer `0` pour les deux supprime la règle.

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/quantity-rules' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

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

  -d '{ "min_qty": 2, "max_qty": 10 }'
```

Renvoie `200` avec la règle enregistrée, dans la forme du GET ci-dessus.

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

| Code               | HTTP | Cause                                                                                                                                                                    |
| ------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `validation_error` | 422  | Une valeur qui n'est pas un entier, inférieure à 0 ou supérieure à 10000, ou un `max_qty` inférieur à `min_qty` (cette règle bloquerait toutes les commandes du produit) |
| `not_found`        | 404  | Produit absent de votre boutique                                                                                                                                         |
