# Products

The product is the core sellable unit on a store. All product calls are scoped to the calling key's store — you can never accidentally touch another merchant's data.

## `GET /v1/products`

List products. Cursor-paginated. Served fresh on every call: a `GET` sent right after a write returns the new values.

**Auth:** platform key with `products:read` (granted by default). A key without it gets `403 forbidden`.

### Query parameters

| Param    | Type                              | Default | Notes                                       |
| -------- | --------------------------------- | ------- | ------------------------------------------- |
| `limit`  | int (1–200)                       | 50      | Page size                                   |
| `cursor` | string                            | —       | From a prior response's `next_cursor`       |
| `status` | `active` \| `draft` \| `archived` | —       | Filter by status                            |
| `search` | string                            | —       | Match against `name` (LIKE) and exact `sku` |

An unrecognised `status` is ignored rather than rejected — you get the unfiltered list, which **includes `archived` products**. Filter explicitly if you only want live items.

### Request

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

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

### Response 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" }

}
```

Changed in v1.1 — image URLs are now complete

`primary_image` (and `images[].url` on `GET /v1/products/{id}`) is now a **full CDN URL**, ready to use as-is. Before v1.1 both returned a bare filename that callers had to prefix themselves. If your integration builds the prefix manually, drop that logic — the value already starts with `https://`.

## `GET /v1/products/{id}`

Full product detail including images and variants.

**Auth:** platform key with `products:read`.

### Request

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

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

### Response 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"

  }

}
```

Added in v1.1

`images[].alt_text`, the full option fields (`price_adjustment`, `sku`, `show_as_card`, `sort_order`, `is_active`), group `required` / `sort_order`, and the whole `combinations` block are new. `combinations` lists at most 300 entries — `combination_count` is always the true total and `combinations_truncated` tells you when the list was cut.

## `POST /v1/products` — create

**Auth:** platform key with `products:write` and `products:read`. The reply is the product as `GET /v1/products/{id}` returns it, so a key without `products:read` gets `403 forbidden` even though the product was created. **Requires `Idempotency-Key`.**

### Body

| Field                                  | Type                              | Required | Notes                                                                                                                                                                                                                               |
| -------------------------------------- | --------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                                 | string (1–255)                    | ✅       |                                                                                                                                                                                                                                     |
| `price`                                | number ≥ 0                        | ✅       | DZD                                                                                                                                                                                                                                 |
| `compare_price`                        | number ≥ 0 \| null                |          | Strike-through price                                                                                                                                                                                                                |
| `cost_price`                           | number ≥ 0 \| null                |          | Internal only — never shown to customers                                                                                                                                                                                            |
| `description`                          | string                            |          | Long-form, can include line breaks and HTML formatting (bold, lists, headings, links, tables, images); scripts and other unsafe tags are removed. Max 60,000 bytes: longer returns `bad_request` "description exceeds 60000 bytes". |
| `short_description`                    | string ≤ 500                      |          | One-liner                                                                                                                                                                                                                           |
| `sku`                                  | string ≤ 100                      |          | Internal SKU                                                                                                                                                                                                                        |
| `barcode`                              | string ≤ 100                      |          | UPC/EAN                                                                                                                                                                                                                             |
| `weight`                               | number                            |          | kg, for shipping                                                                                                                                                                                                                    |
| `shipping_height` / `width` / `length` | number                            |          | cm                                                                                                                                                                                                                                  |
| `do_insurance`                         | bool                              |          | Force shipping insurance on this item                                                                                                                                                                                               |
| `track_stock`                          | bool                              |          | Default `false`                                                                                                                                                                                                                     |
| `stock_quantity`                       | int ≥ 0                           |          | If `track_stock`                                                                                                                                                                                                                    |
| `low_stock_alert`                      | int ≥ 0                           |          | Default `5`. Drives the dashboard's low-stock badge.                                                                                                                                                                                |
| `variant_stock_enabled`                | bool                              |          | Track stock per variant **option** (Red, L, …)                                                                                                                                                                                      |
| `combination_stock_enabled`            | bool                              |          | Track stock per variant **combination** (Red+L). Implies `variant_stock_enabled`.                                                                                                                                                   |
| `category_id`                          | int                               |          | Must exist in your store. The product joins that category, which becomes its main one; categories it already belongs to are kept. On PATCH, `null` clears the main category without removing the product from any category.         |
| `status`                               | `active` \| `draft` \| `archived` |          | Default `draft`                                                                                                                                                                                                                     |
| `featured`                             | bool                              |          | Default `false`                                                                                                                                                                                                                     |

When `variant_stock_enabled` or `combination_stock_enabled` is true, `track_stock` is auto-disabled (variants own their own stock).

You rarely need these two flags directly: `PUT /v1/products/{id}/variants` sets them for you based on the payload you send (per-option stock or combinations).

### Plan limit

Free: 5 active products. Pro: 300. Unlimited / Enterprise: unlimited. The check counts only products with `status: "active"`, drafts don't count, and the count is always live at the moment of the call. The check runs on **create only**: flipping an existing draft to `active` via PATCH is never blocked, so a free-plan store can exceed 5 active products that way. Because it runs on every create, a store at its limit cannot create a new product even with `status: "draft"`. An unrecognised plan name falls back to the free limit of 5. Hitting the limit returns:

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

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

### Request

```
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"

  }'
```

### Response 200

A successful create returns HTTP **200** (not 201) with the same body as `GET /v1/products/{id}`. Do not branch on `status === 201` — check `data.id` instead. `id`, `slug`, and `created_at` are now populated.

On create, `slug` is always derived from `name` — a `slug` in the body is **ignored**. To set a specific slug, create first, then `PATCH /v1/products/{id}` with `{"slug":"…"}`. Normalisation lowercases and replaces every run of non-letter/non-digit characters with `-` (Unicode-aware — Arabic and accented letters are preserved, so it is NOT `[a-z0-9-]`), trimming to 200 characters; collisions get `-2`, `-3`, … suffixes.

### Errors

| Code                                                         | Cause                                |
| ------------------------------------------------------------ | ------------------------------------ |
| `bad_request` "Body must be valid JSON"                      | Wrong Content-Type or malformed JSON |
| `bad_request` "name is required (1-255 chars)"               | Missing or over-long name            |
| `bad_request` "price must be a non-negative number"          | Bad price                            |
| `bad_request` "category\_id N does not belong to this store" | Cross-store id                       |
| `bad_request` "Plan 'free' allows at most …"                 | Plan limit                           |

## `PATCH /v1/products/{id}` — update

**Auth:** platform key with `products:write` and `products:read`. The reply is the updated product, so a key without `products:read` gets `403 forbidden` even though the change was saved. **Requires `Idempotency-Key`.**

Partial update — send only the fields you want to change. Unspecified fields are preserved.

```
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" }'
```

Returns `200` and the full updated product. If the product doesn't exist (or belongs to another store) you get `404 not_found`.

Renaming via `PATCH { name: ... }` automatically regenerates the `slug` only if you didn't pass `slug` explicitly. Pass `slug` if you want to preserve a specific URL after a rename.

## `DELETE /v1/products/{id}`

**Auth:** platform key with `products:write`. **Requires `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"
```

Response:

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

This is a **hard delete**: the product is removed along with its images, variants, offers, add-ons, combinations and customer reviews. The stored image **files** are not removed by this call, so an image URL you saved earlier can keep loading.

Deletion detaches history; a product used by a landing page cannot be deleted

Past orders keep their line items, and the product name / SKU / price captured at purchase time stays intact, so old orders still read correctly, but the line no longer links to a product (`product_id` becomes `null`). A product that a landing page uses (page-level, or in an order form, order button or product offers section) is refused with `409 product_in_use_by_landing_page`; the error data lists the pages in `landing_pages[]` with `id`, `title` and `slug`. Delete that landing page first (`DELETE /v1/landing-pages/{id}`) or attach another product to it (`PATCH /v1/landing-pages/{id}` with a new `product_id`), then delete the product. Prefer `PATCH { "status": "archived" }` over deletion.

## `POST /v1/products/{id}/images` — add an image

*Added in v1.1.* **Auth:** platform key with `products:write`. **Requires `Idempotency-Key`.**

You give a **public `https` URL**; DZBuild downloads the image server-side, converts and optimises it, and hosts it on the store CDN. There is no file upload through the API — link to the image and we fetch it.

### Body

| Field        | Type          | Required | Notes                                    |
| ------------ | ------------- | -------- | ---------------------------------------- |
| `url`        | string ≤ 2000 | ✅       | Public `https://` link to the image file |
| `alt_text`   | string ≤ 255  |          | Accessibility / SEO text                 |
| `is_primary` | bool          |          | Make this the main product photo         |

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

Rules worth knowing:

* **The first image of a product automatically becomes the primary one.**
* Posting a URL whose bytes are already attached to the product does **not** create a duplicate — you get the existing image back with `"deduplicated": true` (HTTP 200 instead of 201).
* Accepted formats: JPEG, PNG, WebP, GIF, BMP, AVIF, HEIC/HEIF, TIFF. Max **20 MB** and **10000×10000 px**. Images are converted to WebP (EXIF stripped), and an image wider than 2000 px is scaled down to 2000 px wide, keeping its proportions. A WebP file of 3 MB or less and no wider than 2000 px is stored as sent.
* Maximum **20 images per product**.

### Which URLs are accepted

For security, the fetcher only accepts **public** addresses and never follows redirects. A URL is refused (`url_refused`) when it is not `https`, carries credentials (`https://user:pass@…`), uses a port other than 443, is an IP address rather than a hostname, or resolves to a private / internal / cloud-metadata address. A link that answers with a redirect or an error status fails with `image_fetch_failed`; a link that answers with a web page (a login page, for example) or any other file that isn't a supported image fails with `unsupported_image`.

### Errors

| Code                 | HTTP | Cause                                                                                  |
| -------------------- | ---- | -------------------------------------------------------------------------------------- |
| `validation_error`   | 422  | `url` missing or longer than 2000 characters                                           |
| `url_refused`        | 422  | URL rejected by the rules above                                                        |
| `image_fetch_failed` | 422  | Host unreachable, redirect, or non-200                                                 |
| `unsupported_image`  | 422  | Not an image (a web page, for example), unsupported format, or dimensions out of range |
| `image_too_large`    | 422  | Over 20 MB                                                                             |
| `too_many_images`    | 422  | Product already has 20 images                                                          |
| `not_found`          | 404  | Product not in your store                                                              |

## `PATCH /v1/products/{id}/images/{image_id}`

*Added in v1.1.* Update `alt_text`, `sort_order` (0–999), or promote the image with `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 }'
```

A product always keeps exactly one primary image, so `is_primary: false` is rejected with `primary_required` — promote a different image instead.

## `DELETE /v1/products/{id}/images/{image_id}`

*Added in v1.1.* Deletes the image row and its stored files.

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

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

If variant options pointed at this image, those links are cleared (the options themselves survive) — `variant_references_cleared` tells you how many. Deleting the primary image automatically promotes the next one.

## `PUT /v1/products/{id}/variants` — replace variants

*Added in v1.1.* **Auth:** platform key with `products:write`. `Idempotency-Key` is optional: with one, a retry with the same value gets the first answer back; without one, every call runs the full replace again.

This replaces ALL variants of the product

There is no partial variant update. Read the current state with `GET /v1/products/{id}` and send back **everything you want to keep** — anything omitted is deleted. Send `{"groups": []}` to remove all variants.

### Body

| Field                                 | Type                                                            | Required | Notes                                                                                                                |
| ------------------------------------- | --------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `groups`                              | array                                                           | ✅       | Variant groups in display order. `[]` clears all variants.                                                           |
| `groups[].name`                       | string ≤ 100                                                    | ✅       | e.g. `Color`, `Size`. Unique per product.                                                                            |
| `groups[].type`                       | `text` \| `color` \| `image_text` \| `selectable` \| `dropdown` |          | Default `text`. `selectable` = optional multi-select add-on group. `dropdown` = text options shown as a select list. |
| `groups[].required`                   | bool                                                            |          | Default `true` (always `false` for `selectable`)                                                                     |
| `groups[].options[].name`             | string ≤ 100                                                    | ✅       | Unique inside the group                                                                                              |
| `groups[].options[].color_code`       | `#rrggbb`                                                       |          | For `color` groups                                                                                                   |
| `groups[].options[].price_adjustment` | number                                                          |          | Added to (or subtracted from) the base price                                                                         |
| `groups[].options[].stock`            | int ≥ 0 \| null                                                 |          | Per-option stock                                                                                                     |
| `groups[].options[].sku`              | string ≤ 100                                                    |          | Per-option SKU                                                                                                       |
| `groups[].options[].image_id`         | int                                                             |          | Must be an existing image **of this product**                                                                        |
| `groups[].options[].show_as_card`     | bool                                                            |          | Render the option as an image card                                                                                   |
| `combinations`                        | array                                                           |          | Per-combination stock (needs 2+ non-`selectable` groups)                                                             |
| `combinations[].options`              | object                                                          | ✅       | `{ "Color": "Red", "Size": "L" }` — one entry per non-`selectable` group                                             |
| `combinations[].stock`                | int ≥ 0                                                         | ✅       |                                                                                                                      |
| `combinations[].sku`                  | string ≤ 100                                                    |          |                                                                                                                      |
| `combinations[].is_active`            | bool                                                            |          | Default `true`                                                                                                       |

Limits: 10 groups, 100 options per group, 200 options total, 1000 combinations.

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

    ]

  }'
```

Returns the new `variants` + `combinations` block (same shape as `GET /v1/products/{id}`).

### Stock mode is set for you

* **Combinations sent** → per-combination stock (`combination_stock_enabled`), product-level `track_stock` off.
* **No combinations, but options carry `stock`** → per-option stock (`variant_stock_enabled`), `track_stock` off.
* **Neither** → variants are presentation-only; product-level stock keeps working.

### Errors

| Code                          | HTTP | Cause                                                       |
| ----------------------------- | ---- | ----------------------------------------------------------- |
| `validation_error`            | 422  | Bad names, types, colours, numbers, or a limit exceeded     |
| `invalid_image_id`            | 422  | `image_id` is not an image of this product                  |
| `combinations_not_applicable` | 422  | Combinations sent with fewer than 2 non-`selectable` groups |
| `duplicate_combination`       | 422  | Two combinations with the same option set                   |
| `not_found`                   | 404  | Product not in your store                                   |

Validation runs **before** anything is deleted — a rejected payload leaves your existing variants untouched.

## `GET /v1/products/{id}/stock`

Reads the product's stock mode and the current count of every target you can set in that mode. Read it before a stock sync to get the option and combination ids. Unlike `GET /v1/products`, this read is not cached, so it shows a change at once.

**Auth:** platform key with `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` says where the product's stock is counted, and the answer lists the targets of that mode:

| `mode`            | Stock is counted on         | Listed in the answer                                                                                                             |
| ----------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `product`         | The product itself          | `product.stock_quantity`                                                                                                         |
| `variant_options` | Each variant option         | `options[]`                                                                                                                      |
| `combinations`    | Each combination of options | `combinations[]` with `sku`, `is_active` and `options` (group name to option name). `options[]` is listed too, for reading only. |

An option with `"stock": null` and `"unlimited": true` has unlimited stock. The mode follows the variants saved with `PUT /v1/products/{id}/variants` (see above).

## `POST /v1/products/{id}/stock`

Sets or shifts stock counts. **Auth:** platform key with `products:write`. **Requires `Idempotency-Key`.**

### Body

`items` lists 1 to 500 targets. Each item carries exactly one of `set`, `delta` or `"unlimited": true`, and a target may appear only once per request.

| Field               | Type                                   | Required                          | Notes                                                                                                                                          |
| ------------------- | -------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `items[].target`    | `product` \| `option` \| `combination` | ✅                                | Must match the product's `mode`: `product`, `option` for `variant_options`, `combination` for `combinations`                                   |
| `items[].id`        | int ≥ 1                                | ✅ for `option` and `combination` | The id from `GET /v1/products/{id}/stock`                                                                                                      |
| `items[].set`       | int, 0 to 9999999                      |                                   | New count                                                                                                                                      |
| `items[].delta`     | int, not 0                             |                                   | Units to add, negative to remove. The result stays between 0 and 9999999.                                                                      |
| `items[].unlimited` | bool                                   |                                   | Options only. `true` makes the option unlimited. An option that is unlimited today needs `"unlimited": false` next to `set` to start counting. |

A `set` or `delta` on the `product` target also turns `track_stock` on, so the store counts that product's stock from then on.

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

    ]

  }'
```

Returns `200` with the stock after the change, in the GET shape above: option 41 now reads `20` and option 42 reads `8`. The items are saved together: if one is refused, none is saved. The change goes into the store's change history (`GET /v1/changes`), and `POST /v1/changes/{id}/undo` puts the previous counts back.

### Errors

| Code                     | HTTP | Cause                                                                                                                                                                                                             |
| ------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`       | 422  | `items` missing, empty or longer than 500; a bad `target`, `id`, `set` or `delta`; the same target twice; not exactly one of `set`, `delta`, `"unlimited": true`; `"unlimited": true` on a product or combination |
| `option_stock_unlimited` | 422  | The option is unlimited today: a `set` without `"unlimited": false`, or any `delta`                                                                                                                               |
| `stock_mode_mismatch`    | 409  | `target` does not match the product's stock mode                                                                                                                                                                  |
| `not_found`              | 404  | Product not in your store, or the option or combination is not part of this product                                                                                                                               |

## `GET /v1/products/{id}/offers`

Reads the product's quantity offers, the bundles shown on the product page.

**Auth:** platform key with `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` is the bundle total

`price` is what the buyer pays for all `quantity` units together, not a unit price. On a 1000 DZD product, "Buy 2, get 1 free" is `"quantity": 3, "price": 2000`. An offer with a `discount_type` stores `price` as `0` and takes `discount_value` off the product price times the quantity: `amount` subtracts DZD, `percent` takes a percentage and also scales variant price adjustments. The second offer above sells 2 units for 1800 DZD.

`image_url` is the full CDN link of the offer picture, `null` when it has none.

## `POST /v1/products/{id}/offers`

Replaces the product's quantity offers. **Auth:** platform key with `products:write`. **Requires `Idempotency-Key`.**

This replaces ALL offers of the product

Send every offer you want to keep, in display order. Anything omitted is deleted. Send `{"offers": []}` to remove all offers.

### Body

| Field                     | Type                          | Required                   | Notes                                                                    |
| ------------------------- | ----------------------------- | -------------------------- | ------------------------------------------------------------------------ |
| `offers`                  | array (0 to 50)               | ✅                         | Offers in display order. `[]` removes all offers.                        |
| `offers[].title`          | string                        | ✅                         | Cut to 255 characters                                                    |
| `offers[].quantity`       | int, 1 to 9999                | ✅                         | Units in the bundle. Each offer needs a different quantity.              |
| `offers[].price`          | number > 0                    | ✅ without `discount_type` | Total for the whole bundle, in DZD. Ignored when `discount_type` is set. |
| `offers[].discount_type`  | `amount` \| `percent` \| null |                            | Default `null` (fixed bundle price)                                      |
| `offers[].discount_value` | number > 0                    | ✅ with `discount_type`    | DZD for `amount`, at most 100 for `percent`                              |
| `offers[].compare_price`  | number ≥ 0 \| null            |                            | Strike-through price, in DZD                                             |
| `offers[].badge_text`     | string                        |                            | Cut to 100 characters                                                    |
| `offers[].badge_color`    | `#rgb` or `#rrggbb`           |                            | Default `#10b981`                                                        |
| `offers[].free_shipping`  | bool                          |                            | Delivery is free when the buyer orders this offer. Default `false`.      |
| `offers[].image_path`     | string                        |                            | Keeps the offer picture: send back the `image_path` that GET returned    |
| `offers[].is_active`      | bool                          |                            | Default `true`                                                           |

Offer pictures cannot be uploaded through the API: add them in the dashboard. A picture whose `image_path` you leave out is deleted.

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

    ]

  }'
```

Returns `200` with the new offers, in the GET shape above. A rejected payload leaves your existing offers untouched.

### Errors

| Code                       | HTTP | Cause                                                                                                                                                                     |
| -------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`         | 422  | `offers` missing or not a list, more than 50 offers, a missing `title`, or a bad `quantity`, `price`, `discount_type`, `discount_value`, `compare_price` or `badge_color` |
| `duplicate_offer_quantity` | 422  | Two offers with the same `quantity`                                                                                                                                       |
| `invalid_image_path`       | 422  | `image_path` is not a picture this product's offers already use                                                                                                           |
| `not_found`                | 404  | Product not in your store                                                                                                                                                 |

## `GET /v1/products/{id}/addons`

Reads the product's buyer input fields: extra fields the buyer fills in on the product page (a short text, a long text or a picture upload), and the `enabled` switch that shows or hides them.

**Auth:** platform key with `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 }

    ]

  }

}
```

The product page shows the fields only while `enabled` is `true`, and only the fields with `"is_active": true`. `extra_price` is added to the order when the buyer fills that field.

## `POST /v1/products/{id}/addons`

Replaces the product's buyer input fields and can set the `enabled` switch in the same call. **Auth:** platform key with `products:write`. **Requires `Idempotency-Key`.**

This replaces ALL input fields of the product

Send every field you want to keep, in display order. Anything omitted is deleted. Send `{"addons": []}` to remove all fields.

### Body

| Field                         | Type                            | Required | Notes                                                                                                          |
| ----------------------------- | ------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `enabled`                     | bool \| null                    |          | `true` shows the fields on the product page, `false` hides them. Omitted or `null` leaves the switch as it is. |
| `addons`                      | array (0 to 20)                 | ✅       | Fields in display order. `[]` removes all fields.                                                              |
| `addons[].title`              | string                          | ✅       | Cut to 255 characters                                                                                          |
| `addons[].input_type`         | `text` \| `textarea` \| `image` |          | Default `text`. `textarea` is a long text, `image` a picture upload.                                           |
| `addons[].placeholder`        | string                          |          | Cut to 255 characters                                                                                          |
| `addons[].is_required`        | bool                            |          | Default `false`                                                                                                |
| `addons[].extra_price`        | number ≥ 0                      |          | DZD added when the buyer fills the field. Default `0`.                                                         |
| `addons[].max_length`         | int ≥ 1                         |          | `text` and `textarea` fields only. Capped at 65535.                                                            |
| `addons[].allowed_extensions` | list or comma-separated string  |          | `image` fields only: any of `jpg`, `jpeg`, `png`, `gif`, `webp`. Default all five.                             |
| `addons[].is_active`          | bool                            |          | Default `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"] }

    ]

  }'
```

Returns `200` with the new fields and the switch, in the GET shape above. A rejected payload leaves your existing fields untouched.

### Errors

| Code               | HTTP | Cause                                                                                                                                                                                                                                          |
| ------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error` | 422  | `addons` missing or not a list, more than 20 fields, a missing `title`, an unknown `input_type`, a bad `extra_price` or `max_length`, a `max_length` on an `image` field, `allowed_extensions` on another field type or with another extension |
| `not_found`        | 404  | Product not in your store                                                                                                                                                                                                                      |

## `GET /v1/products/{id}/quantity-rules`

Reads the minimum and maximum quantity of this product that one order can contain. `0` means no limit.

**Auth:** platform key with `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."

  }

}
```

Checkout applies the rule only while the [Minimum & Maximum Quantity Per Product](https://dzbuild.com/docs/addons/min-max-quantity.md) add-on is active on the store. The add-on is available on every plan. `addon_active` tells you whether it is on, and `warning` appears when a rule is saved but the add-on is off.

## `POST /v1/products/{id}/quantity-rules`

Sets the product's minimum and maximum order quantity. **Auth:** platform key with `products:write`. **Requires `Idempotency-Key`.**

### Body

| Field     | Type            | Required | Notes                                                                              |
| --------- | --------------- | -------- | ---------------------------------------------------------------------------------- |
| `min_qty` | int, 0 to 10000 |          | `0` = no minimum. Omitted counts as `0`.                                           |
| `max_qty` | int, 0 to 10000 |          | `0` = no maximum. Omitted counts as `0`. Above `0`, it must be at least `min_qty`. |

Each call writes both values, so send them together: a field you leave out becomes `0`. Sending `0` for both removes the rule.

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

Returns `200` with the saved rule, in the GET shape above.

### Errors

| Code               | HTTP | Cause                                                                                                                                         |
| ------------------ | ---- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error` | 422  | A value that is not a whole number, below 0 or above 10000, or a `max_qty` below `min_qty` (that rule would block every order of the product) |
| `not_found`        | 404  | Product not in your store                                                                                                                     |
