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

ParamTypeDefaultNotes
limitint (1–200)50Page size
cursorstring—From a prior response's next_cursor
statusactive | draft | archived—Filter by status
searchstring—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/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" }
}
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"
}
}
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​

FieldTypeRequiredNotes
namestring (1–255)✅
pricenumber ≥ 0✅DZD
compare_pricenumber ≥ 0 | nullStrike-through price
cost_pricenumber ≥ 0 | nullInternal only — never shown to customers
descriptionstringLong-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 bad_request "description exceeds 60000 bytes".
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. The product joins that category, which becomes its main one; categories it already belongs to are kept. On PATCH, null clears the main category without removing the product from any category.
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. 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​

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

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​

FieldTypeRequiredNotes
urlstring ≤ 2000✅Public 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/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​

CodeHTTPCause
validation_error422url missing or longer than 2000 characters
url_refused422URL rejected by the rules above
image_fetch_failed422Host unreachable, redirect, or non-200
unsupported_image422Not an image (a web page, for example), unsupported 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. 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.

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
groupsarray✅Variant groups in display order. [] clears all variants.
groups[].namestring ≤ 100✅e.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 ≤ 100✅Unique 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.

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:

modeStock is counted onListed in the answer
productThe product itselfproduct.stock_quantity
variant_optionsEach variant optionoptions[]
combinationsEach combination of optionscombinations[] with sku, is_active and options (group name to option name). options[] is listed too, for reading only.

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.

FieldTypeRequiredNotes
items[].targetproduct | option | combination✅Must match the product's mode: product, option for variant_options, combination for combinations
items[].idint ≥ 1✅ for option and combinationThe id from GET /v1/products/{id}/stock
items[].setint, 0 to 9999999New count
items[].deltaint, not 0Units to add, negative to remove. The result stays between 0 and 9999999.
items[].unlimitedboolOptions only. true makes the option unlimited. An option that is unlimited today needs "unlimited": false next to set to start counting.

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​

CodeHTTPCause
validation_error422items missing, empty or longer than 500; a bad target, id, set or delta; the same target twice; not exactly one of set, delta, "unlimited": true; "unlimited": true on a product or combination
option_stock_unlimited422The option is unlimited today: a set without "unlimited": false, or any delta
stock_mode_mismatch409target does not match the product's stock mode
not_found404Product 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 }
]
}
}
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.

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​

FieldTypeRequiredNotes
offersarray (0 to 50)✅Offers in display order. [] removes all offers.
offers[].titlestring✅Cut to 255 characters
offers[].quantityint, 1 to 9999✅Units in the bundle. Each offer needs a different quantity.
offers[].pricenumber > 0✅ without discount_typeTotal for the whole bundle, in DZD. Ignored when discount_type is set.
offers[].discount_typeamount | percent | nullDefault null (fixed bundle price)
offers[].discount_valuenumber > 0✅ with discount_typeDZD for amount, at most 100 for percent
offers[].compare_pricenumber ≥ 0 | nullStrike-through price, in DZD
offers[].badge_textstringCut to 100 characters
offers[].badge_color#rgb or #rrggbbDefault #10b981
offers[].free_shippingboolDelivery is free when the buyer orders this offer. Default false.
offers[].image_pathstringKeeps the offer picture: send back the image_path that GET returned
offers[].is_activeboolDefault true

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​

CodeHTTPCause
validation_error422offers missing or not a list, more than 50 offers, a missing title, or a bad quantity, price, discount_type, discount_value, compare_price or badge_color
duplicate_offer_quantity422Two offers with the same quantity
invalid_image_path422image_path is not a picture this product's offers already use
not_found404Product 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.

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​

FieldTypeRequiredNotes
enabledbool | nulltrue shows the fields on the product page, false hides them. Omitted or null leaves the switch as it is.
addonsarray (0 to 20)✅Fields in display order. [] removes all fields.
addons[].titlestring✅Cut to 255 characters
addons[].input_typetext | textarea | imageDefault text. textarea is a long text, image a picture upload.
addons[].placeholderstringCut to 255 characters
addons[].is_requiredboolDefault false
addons[].extra_pricenumber ≥ 0DZD added when the buyer fills the field. Default 0.
addons[].max_lengthint ≥ 1text and textarea fields only. Capped at 65535.
addons[].allowed_extensionslist or comma-separated stringimage fields only: any of jpg, jpeg, png, gif, webp. Default all five.
addons[].is_activeboolDefault true
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​

CodeHTTPCause
validation_error422addons missing or not a list, more than 20 fields, a missing title, an unknown input_type, a bad extra_price or max_length, a max_length on an image field, allowed_extensions on another field type or with another extension
not_found404Product 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​

FieldTypeRequiredNotes
min_qtyint, 0 to 100000 = no minimum. Omitted counts as 0.
max_qtyint, 0 to 100000 = no maximum. Omitted counts as 0. Above 0, it must be at least min_qty.

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​

CodeHTTPCause
validation_error422A value that is not a whole number, below 0 or above 10000, or a max_qty below min_qty (that rule would block every order of the product)
not_found404Product not in your store
This page for AI toolsView as MarkdownOpen in ChatGPTOpen in Claude