Skip to main content

Products

Full CRUD for the product catalog — list, get, create, update, delete. With variants, images, plan limits.

Written by Support

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

ℹ️ 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

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.

⚠️ 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

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.

⚠️ 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

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

Default text. selectable = optional multi-select add-on group.

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.

Did this answer your question?