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 |
| int (1–200) | 50 | Page size |
| string | — | From a prior response's |
|
| — | Filter by status |
| string | — | Match against |
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" }
}
ℹ️ Info — 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"
}
}
ℹ️ Info — 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 |
| string (1–255) | ✅ | |
| number ≥ 0 | ✅ | DZD |
| number ≥ 0 | null | Strike-through price | |
| number ≥ 0 | null | Internal only — never shown to customers | |
| 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 | |
| string ≤ 500 | One-liner | |
| string ≤ 100 | Internal SKU | |
| string ≤ 100 | UPC/EAN | |
| number | kg, for shipping | |
| number | cm | |
| bool | Force shipping insurance on this item | |
| bool | Default | |
| int ≥ 0 | If | |
| int ≥ 0 | Default | |
| bool | Track stock per variant option (Red, L, …) | |
| bool | Track stock per variant combination (Red+L). Implies | |
| 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, | |
|
| Default | |
| bool | Default |
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 |
| Wrong Content-Type or malformed JSON |
| Missing or over-long name |
| Bad price |
| Cross-store id |
| 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.
⚠️ Warning — 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 |
| string ≤ 2000 | ✅ | Public |
| string ≤ 255 | Accessibility / SEO text | |
| 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 |
| 422 |
|
| 422 | URL rejected by the rules above |
| 422 | Host unreachable, redirect, or non-200 |
| 422 | Not an image (a web page, for example), unsupported format, or dimensions out of range |
| 422 | Over 20 MB |
| 422 | Product already has 20 images |
| 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.
⚠️ Warning — 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 |
| array | ✅ | Variant groups in display order. |
| string ≤ 100 | ✅ | e.g. |
|
| Default | |
| bool | Default | |
| string ≤ 100 | ✅ | Unique inside the group |
|
| For | |
| number | Added to (or subtracted from) the base price | |
| int ≥ 0 | null | Per-option stock | |
| string ≤ 100 | Per-option SKU | |
| int | Must be an existing image of this product | |
| bool | Render the option as an image card | |
| array | Per-combination stock (needs 2+ non- | |
| object | ✅ |
|
| int ≥ 0 | ✅ | |
| string ≤ 100 | ||
| bool | Default |
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-leveltrack_stockoff.No combinations, but options carry
stock→ per-option stock (variant_stock_enabled),track_stockoff.Neither → variants are presentation-only; product-level stock keeps working.
Errors
Code | HTTP | Cause |
| 422 | Bad names, types, colours, numbers, or a limit exceeded |
| 422 |
|
| 422 | Combinations sent with fewer than 2 non- |
| 422 | Two combinations with the same option set |
| 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:
| Stock is counted on | Listed in the answer |
| The product itself |
|
| Each variant option |
|
| Each combination of options |
|
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 |
|
| ✅ | Must match the product's |
| int ≥ 1 | ✅ for | The id from |
| int, 0 to 9999999 | New count | |
| int, not 0 | Units to add, negative to remove. The result stays between 0 and 9999999. | |
| bool | Options only. |
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 |
| 422 |
|
| 422 | The option is unlimited today: a |
| 409 |
|
| 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 }
]
}
}
⚠️ Warning — 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.
⚠️ Warning — 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 |
| array (0 to 50) | ✅ | Offers in display order. |
| string | ✅ | Cut to 255 characters |
| int, 1 to 9999 | ✅ | Units in the bundle. Each offer needs a different quantity. |
| number > 0 | ✅ without | Total for the whole bundle, in DZD. Ignored when |
|
| Default | |
| number > 0 | ✅ with | DZD for |
| number ≥ 0 | null | Strike-through price, in DZD | |
| string | Cut to 100 characters | |
|
| Default | |
| bool | Delivery is free when the buyer orders this offer. Default | |
| string | Keeps the offer picture: send back the | |
| bool | Default |
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 |
| 422 |
|
| 422 | Two offers with the same |
| 422 |
|
| 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.
⚠️ Warning — 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 |
| bool | null |
| |
| array (0 to 20) | ✅ | Fields in display order. |
| string | ✅ | Cut to 255 characters |
|
| Default | |
| string | Cut to 255 characters | |
| bool | Default | |
| number ≥ 0 | DZD added when the buyer fills the field. Default | |
| int ≥ 1 |
| |
| list or comma-separated string |
| |
| bool | Default |
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 |
| 422 |
|
| 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 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 |
| int, 0 to 10000 |
| |
| int, 0 to 10000 |
|
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 |
| 422 | A value that is not a whole number, below 0 or above 10000, or a |
| 404 | Product not in your store |