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
| 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/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" }
}
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"
}
}
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
| 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 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_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. 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. | |
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. 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
| 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 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.
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
| 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/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
| Code | HTTP | Cause |
|---|---|---|
validation_error | 422 | url missing or longer than 2000 characters |
url_refused | 422 | URL rejected by the rules above |
image_fetch_failed | 422 | Host unreachable, redirect, or non-200 |
unsupported_image | 422 | Not an image (a web page, for example), 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. 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.
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.
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:
mode | Stock is counted on | Listed in the answer |
|---|---|---|
product | The product itself | product.stock_quantity |
variant_options | Each variant option | options[] |
combinations | Each combination of options | combinations[] 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.
| Field | Type | Required | Notes |
|---|---|---|---|
items[].target | product | option | combination | ✅ | Must match the product's mode: product, option for variant_options, combination for combinations |
items[].id | int ≥ 1 | ✅ for option and combination | The id from GET /v1/products/{id}/stock |
items[].set | int, 0 to 9999999 | New count | |
items[].delta | int, not 0 | Units to add, negative to remove. The result stays between 0 and 9999999. | |
items[].unlimited | bool | Options 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
| Code | HTTP | Cause |
|---|---|---|
validation_error | 422 | items 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_unlimited | 422 | The option is unlimited today: a set without "unlimited": false, or any delta |
stock_mode_mismatch | 409 | target does not match the product's stock mode |
not_found | 404 | Product 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 totalprice 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.
Send every offer you want to keep, in display order. Anything omitted is deleted. Send {"offers": []} to remove all offers.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
offers | array (0 to 50) | ✅ | Offers in display order. [] removes all offers. |
offers[].title | string | ✅ | Cut to 255 characters |
offers[].quantity | int, 1 to 9999 | ✅ | Units in the bundle. Each offer needs a different quantity. |
offers[].price | number > 0 | ✅ without discount_type | Total for the whole bundle, in DZD. Ignored when discount_type is set. |
offers[].discount_type | amount | percent | null | Default null (fixed bundle price) | |
offers[].discount_value | number > 0 | ✅ with discount_type | DZD for amount, at most 100 for percent |
offers[].compare_price | number ≥ 0 | null | Strike-through price, in DZD | |
offers[].badge_text | string | Cut to 100 characters | |
offers[].badge_color | #rgb or #rrggbb | Default #10b981 | |
offers[].free_shipping | bool | Delivery is free when the buyer orders this offer. Default false. | |
offers[].image_path | string | Keeps the offer picture: send back the image_path that GET returned | |
offers[].is_active | bool | Default 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
| Code | HTTP | Cause |
|---|---|---|
validation_error | 422 | offers 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_quantity | 422 | Two offers with the same quantity |
invalid_image_path | 422 | image_path is not a picture this product's offers already use |
not_found | 404 | Product 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.
Send every field you want to keep, in display order. Anything omitted is deleted. Send {"addons": []} to remove all fields.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
enabled | bool | null | true shows the fields on the product page, false hides them. Omitted or null leaves the switch as it is. | |
addons | array (0 to 20) | ✅ | Fields in display order. [] removes all fields. |
addons[].title | string | ✅ | Cut to 255 characters |
addons[].input_type | text | textarea | image | Default text. textarea is a long text, image a picture upload. | |
addons[].placeholder | string | Cut to 255 characters | |
addons[].is_required | bool | Default false | |
addons[].extra_price | number ≥ 0 | DZD added when the buyer fills the field. Default 0. | |
addons[].max_length | int ≥ 1 | text and textarea fields only. Capped at 65535. | |
addons[].allowed_extensions | list or comma-separated string | image fields only: any of jpg, jpeg, png, gif, webp. Default all five. | |
addons[].is_active | bool | Default 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
| Code | HTTP | Cause |
|---|---|---|
validation_error | 422 | addons 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_found | 404 | Product 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
| Field | Type | Required | Notes |
|---|---|---|---|
min_qty | int, 0 to 10000 | 0 = no minimum. Omitted counts as 0. | |
max_qty | int, 0 to 10000 | 0 = 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
| Code | HTTP | Cause |
|---|---|---|
validation_error | 422 | A 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_found | 404 | Product not in your store |