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. Cached for 30 s — check the X-Cache: HIT|MISS response header.
Auth: any active platform key for the store. The products:read scope is granted by default and is not separately enforced at v1; only products:write is checked, on POST/PATCH/DELETE.
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/13/13_1768313552_b33d660c_1562f6687591.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: any active platform key for the store (products:read is not separately enforced at v1).
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/13/13_1768313552_b33d660c_1562f6687591.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. 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 | |
| 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 | |
|
| 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. 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. 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, and combinations. The stored image files are cleaned up separately shortly afterwards, so the API call returns without waiting on them.
⚠️ Warning — Deletion detaches history and breaks linked landing pages
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). Any landing page pointing at the product loses its product_id, which breaks that page's order form (a landing page with no product id is a known cause of mis-priced orders). 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/13/13_1786570549_77c4_4d0c.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 re-encoded (EXIF stripped) and resized to fit 2000×2000.
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, a login page, or anything that isn't an image fails with image_fetch_failed.
Errors
Code | HTTP | Cause |
| 422 | URL rejected by the rules above |
| 422 | Host unreachable, redirect, non-200, or not an image |
| 422 | 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. Requires Idempotency-Key.
⚠️ 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.