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. 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 |
|---|---|---|---|
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/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" }
}
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"
}
}
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 |
|---|---|---|---|
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 | |
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 | |
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. 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. 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.
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 |
|---|---|---|---|
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/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 |
|---|---|---|
url_refused | 422 | URL rejected by the rules above |
image_fetch_failed | 422 | Host unreachable, redirect, non-200, or not an image |
unsupported_image | 422 | 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. Requires Idempotency-Key.
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-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 |
|---|---|---|
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.