Skip to main content

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

ParamTypeDefaultNotes
limitint (1–200)50Page size
cursorstringFrom a prior response's next_cursor
statusactive | draft | archivedFilter by status
searchstringMatch 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" }
}
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"
}
}
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

FieldTypeRequiredNotes
namestring (1–255)
pricenumber ≥ 0DZD
compare_pricenumber ≥ 0 | nullStrike-through price
cost_pricenumber ≥ 0 | nullInternal only — never shown to customers
descriptionstringLong-form, can include line breaks
short_descriptionstring ≤ 500One-liner
skustring ≤ 100Internal SKU
barcodestring ≤ 100UPC/EAN
weightnumberkg, for shipping
shipping_height / width / lengthnumbercm
do_insuranceboolForce shipping insurance on this item
track_stockboolDefault false
stock_quantityint ≥ 0If track_stock
low_stock_alertint ≥ 0Default 5. Drives the dashboard's low-stock badge.
variant_stock_enabledboolTrack stock per variant option (Red, L, …)
combination_stock_enabledboolTrack stock per variant combination (Red+L). Implies variant_stock_enabled.
category_idintMust exist in your store
statusactive | draft | archivedDefault draft
featuredboolDefault 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

CodeCause
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.

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

FieldTypeRequiredNotes
urlstring ≤ 2000Public https:// link to the image file
alt_textstring ≤ 255Accessibility / SEO text
is_primaryboolMake 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

CodeHTTPCause
url_refused422URL rejected by the rules above
image_fetch_failed422Host unreachable, redirect, non-200, or not an image
unsupported_image422Unsupported format or dimensions out of range
image_too_large422Over 20 MB
too_many_images422Product already has 20 images
not_found404Product 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.

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

FieldTypeRequiredNotes
groupsarrayVariant groups in display order. [] clears all variants.
groups[].namestring ≤ 100e.g. Color, Size. Unique per product.
groups[].typetext | color | image_text | selectable | dropdownDefault text. selectable = optional multi-select add-on group. dropdown = text options shown as a select list.
groups[].requiredboolDefault true (always false for selectable)
groups[].options[].namestring ≤ 100Unique inside the group
groups[].options[].color_code#rrggbbFor color groups
groups[].options[].price_adjustmentnumberAdded to (or subtracted from) the base price
groups[].options[].stockint ≥ 0 | nullPer-option stock
groups[].options[].skustring ≤ 100Per-option SKU
groups[].options[].image_idintMust be an existing image of this product
groups[].options[].show_as_cardboolRender the option as an image card
combinationsarrayPer-combination stock (needs 2+ non-selectable groups)
combinations[].optionsobject{ "Color": "Red", "Size": "L" } — one entry per non-selectable group
combinations[].stockint ≥ 0
combinations[].skustring ≤ 100
combinations[].is_activeboolDefault 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

CodeHTTPCause
validation_error422Bad names, types, colours, numbers, or a limit exceeded
invalid_image_id422image_id is not an image of this product
combinations_not_applicable422Combinations sent with fewer than 2 non-selectable groups
duplicate_combination422Two combinations with the same option set
not_found404Product not in your store

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