Orders
Orders are the heart of the platform. Every order:
- Belongs to exactly one store (scoped by your API key — you can never accidentally touch another merchant's data).
- Has a 7-state lifecycle.
- Is tied to a customer (deduped by phone within the store).
- Has 1+ items, each optionally with variants and add-ons.
- Has a payment status (
pending,paid,refunded) independent of the fulfillment status.
Lifecycle
pending → confirmed → processing → shipped → delivered
↘ returned
↘ cancelled
| State | Meaning | Stock |
|---|---|---|
pending | Created via storefront or API. Awaiting merchant confirmation. | Not committed |
confirmed | Merchant confirmed (called customer, reviewed cart). | Committed (decremented) |
processing | Being packed / preparing for courier pickup. | Committed |
shipped | Handed to courier. | Committed |
delivered | Customer signed for it. | Committed |
cancelled | Order cancelled — stock restored if it had been committed. | Restored |
returned | Customer returned the item — stock restored. | Restored |
cancelled and returned are terminal — you cannot transition out of them.
POST /v1/orders — create an order
Create a new order for the calling store. Used by custom themes, mobile / native apps, resellers, and any headless storefront that wants to bypass the cookie-based legacy /api/orders endpoint.
Auth: platform key with orders:write. Requires Idempotency-Key.
The order is created in pending status. Stock is NOT committed at create time — the first pending → confirmed transition is what decrements stock (same as the dashboard flow). This is intentional: it lets your operations team filter out fake / duplicate / no-answer orders before any inventory is touched.
Body
{
"customer": {
"name": "Sarra Benali",
"phone": "0555000111",
"wilaya_id": 16,
"commune": "Bab Ezzouar",
"address": "12 Rue X, Apt 3"
},
"delivery": {
"type": "home",
"desk_id": null,
"desk_name": null
},
"items": [
{
"product_id": 26,
"quantity": 2,
"variants": [
{ "group_name": "Color", "option_name": "Red", "color_code": "#ff0000", "price_adjustment": 0 },
{ "group_name": "Size", "option_name": "L", "color_code": null, "price_adjustment": 200 }
]
}
],
"shipping_cost": 600,
"discount": 0,
"payment_fee": 0,
"payment_method": "cod",
"notes": "Please call before delivery"
}
Field reference
customer (object, required)
| Field | Type | Required | Notes |
|---|---|---|---|
name | string (1–255) | ✅ | Full name |
phone | string | ✅ | ^\+?[0-9 ]{6,20}$ — Algerian or international |
email | string | null | If present, will be saved on customer record | |
wilaya_id | int 1–58 | ✅ | Algerian wilaya code |
commune | string (1–100) | ✅ | Free text, e.g. "Bab Ezzouar" |
address | string | Street + apt; can be empty for stop-desk |
Customers are deduped by (store_id, phone). If a customer with this phone already exists in your store, their record is updated (name, wilaya, commune, address, email) and reused. If not, a new customer record is created.
delivery (object, optional)
| Field | Type | Default | Notes |
|---|---|---|---|
type | home | desk | digital | home | digital is for downloadable products only |
desk_id | int | null | null | Required if type = desk and you want a specific pickup desk |
desk_name | string | null | null | Optional human-readable label |
items (array, required, 1–50 lines)
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | int | ✅ | Must belong to your store (cross-store IDs are rejected with 400) |
quantity | int 1–9999 | ✅ | |
variants | array of variant objects | See below |
Important — server-authoritative pricing. You do not specify the line price. We always pull products.price server-side and add up variants[].price_adjustment. If you send a price field, it is ignored. This prevents a compromised client from pushing through orders at fake-low prices.
items[].variants (array, optional)
Each variant object describes a chosen option for a variant group on the product:
| Field | Type | Notes |
|---|---|---|
group_name | string | e.g. "Color", "Size", "Material" |
option_name | string | e.g. "Red", "L", "Cotton" |
color_code | string | null | Hex color (only for color-type variants) |
price_adjustment | number | Added to base price; can be negative for a discount |
Send one variant object per chosen group for that line item. So a "Red T-shirt size L" becomes 2 variant entries (one for Color/Red, one for Size/L). DZBuild renders these on the dashboard order page exactly the same way as if a customer picked them on the storefront.
For products using per-piece variants (e.g. a "buy 3 t-shirts, pick a color for each" offer), use quantity = 1 per line and create one line per piece — that's the cleanest mapping.
Top-level money fields
| Field | Type | Default | Notes |
|---|---|---|---|
shipping_cost | number ≥ 0 | 0 | You compute this client-side from the wilaya + delivery type |
discount | number ≥ 0 | 0 | Promo code amount, manual rebate, etc. |
payment_fee | number ≥ 0 | 0 | Online payment processor fee |
payment_method | cod | free_digital | digital_payment | auto | Defaults to cod for physical, free_digital for digital orders |
notes | string ≤ 1000 | null | Customer notes, visible on the dashboard order page |
Total is computed server-side as subtotal + shipping_cost - discount + payment_fee (clamped to 0). subtotal itself is sum(items[].quantity × (price + Σ variants.price_adjustment)).
Request
curl -X POST 'https://api.dzbuild.app/v1/orders' \
-H "Authorization: Bearer $DZ_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"customer": {
"name": "Sarra Benali",
"phone": "0555000111",
"wilaya_id": 16,
"commune": "Bab Ezzouar",
"address": "12 Rue X"
},
"items": [
{ "product_id": 26, "quantity": 1,
"variants": [
{ "group_name": "Duration", "option_name": "30 days", "price_adjustment": 0 }
]
}
],
"shipping_cost": 600,
"payment_method": "cod"
}'
Response 201
Returns the same shape as GET /v1/orders/{id} — fully populated with computed totals, normalized customer block, and the line items you just created (with their variants). id and order_number are the new values.
Errors
| Code | Cause |
|---|---|
bad_request "customer object is required" | Missing customer |
bad_request "customer.phone is required (digits, optional leading +)" | Phone failed regex |
bad_request "customer.wilaya_id must be 1-58" | Bad wilaya |
bad_request "items must be a non-empty array" | Empty cart |
bad_request "items: max 50 lines per order" | Too many lines (split into multiple orders) |
bad_request "items[N].product_id is required" | Missing product_id |
bad_request "Product N does not belong to this store" | Cross-store id |
bad_request "items[N].quantity must be 1-9999" | Bad qty |
bad_request "delivery.type must be home, desk, or digital" | Bad delivery type |
bad_request "payment_method must be cod, free_digital, or digital_payment" | Bad payment method |
bad_request "Monthly order limit reached for this store plan" | Pro plan capped at 30 orders/month — upgrade |
Idempotency
Every POST must carry an Idempotency-Key header. If you retry the same request (same key, same store) within 24 h we return the same response — the order is created exactly once. See Idempotency.
# safe to retry indefinitely with the same key
KEY="$(uuidgen)"
for i in 1 2 3; do
curl -X POST 'https://api.dzbuild.app/v1/orders' \
-H "Authorization: Bearer $DZ_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @order.json
done
# only one row in the orders table
Webhook fired
Creating an order fires order.created to all webhooks subscribed to that event. See Webhook events.
GET /v1/orders
List orders. Cursor-paginated.
Auth: platform key with orders:read.
Query parameters
| Param | Type | Notes |
|---|---|---|
limit | int 1–200 | Default 50 |
cursor | string | Opaque |
status | one of the 7 states | Filter |
since | ISO 8601 string | created_at >= since |
customer_phone | string | Exact match |
Request
curl 'https://api.dzbuild.app/v1/orders?status=pending&limit=20' \
-H "Authorization: Bearer $DZ_KEY"
Response 200
{
"data": {
"items": [
{
"id": 6894,
"order_number": "ORD-13-20260317-AD3C",
"status": "pending",
"payment_status": "pending",
"payment_method": "cod",
"total": 1000,
"customer_name": "John Doe",
"customer_phone": "0555000000",
"wilaya_id": 16,
"commune": "Bab Ezzouar",
"delivery_type": "home",
"created_at": "2026-03-17 15:18:13"
}
],
"next_cursor": null,
"has_more": false
}
}
The list view is intentionally compact (no items, no variants). Call GET /v1/orders/{id} for the full detail.
GET /v1/orders/{id}
Full detail with line items, variants, and customer block.
Auth: platform key with orders:read.
Response 200
{
"data": {
"id": 6894,
"order_number": "ORD-13-20260317-AD3C",
"status": "pending",
"payment_status": "pending",
"payment_method": "cod",
"customer": {
"id": 5578,
"name": "John Doe",
"phone": "0555000000",
"email": null,
"wilaya_id": 16,
"commune": "Bab Ezzouar",
"address": "12 Rue X"
},
"delivery": { "type": "home", "desk_id": null, "desk_name": null },
"amounts": {
"subtotal": 1000,
"shipping_cost": 0,
"discount": 0,
"payment_fee": 0,
"total": 1000
},
"items": [
{
"id": 8421,
"product_id": 26,
"price": 1000,
"quantity": 1,
"variants": [
{ "group_name": "Duration", "option_name": "30 days", "color_code": null, "price_adjustment": "0.00" }
]
}
],
"created_at": "2026-03-17 15:18:13",
"updated_at": "2026-03-17 15:18:13"
}
}
Variants in the response
The variants array on each item is the source of truth for what the customer picked. Each entry has group_name, option_name, color_code (for color variants), and price_adjustment (the per-piece add-on price). For multi-piece offers, you may see multiple variant rows on the same item with different effective groupings — see the per-piece notes below.
PATCH /v1/orders/{id} — change status
Auth: platform key with orders:write. Requires Idempotency-Key.
Body must be {"status": "<one of the 7>"}. We validate the transition is allowed; otherwise we return 400 with the legal next states.
Request
curl -X PATCH 'https://api.dzbuild.app/v1/orders/6894' \
-H "Authorization: Bearer $DZ_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: confirm-6894-$(date +%s)" \
-d '{"status": "confirmed"}'
Returns 200 and the updated order detail. The status transition is wrapped in a transaction with SELECT ... FOR UPDATE so concurrent attempts collide cleanly with optimistic concurrency.
Stock side-effects
When the transition crosses the committed-stock boundary:
- Non-committed → committed (e.g.
pending → confirmed) — stock is decremented, sales count is incremented, products cache is invalidated. - Committed → cancelled / returned — stock is restored, products cache invalidated.
- Other transitions don't touch stock.
Stock work uses the legacy OrderController::handleStockChange exactly so behaviour matches the dashboard 1:1.
Errors
| Code | Cause |
|---|---|
bad_request "status must be one of: pending, confirmed, …" | Invalid string |
bad_request "Transition X → Y not allowed. From 'X' you can only go to: …" | Not allowed by the state machine |
bad_request "order status changed concurrently; retry" | Another request changed status while you were transitioning. Safe to retry with same idempotency key. |
not_found | Order id is unknown or belongs to another store |
POST /v1/orders/{id}/cancel
Convenience endpoint. Equivalent to PATCH /v1/orders/{id} with {"status":"cancelled"} but with a slightly more permissive rule: any non-terminal state can transition to cancelled.
Auth: platform key with orders:write. Requires Idempotency-Key.
curl -X POST 'https://api.dzbuild.app/v1/orders/6894/cancel' \
-H "Authorization: Bearer $DZ_KEY" \
-H "Idempotency-Key: cancel-6894-$(date +%s)"
Variants — full reference
Variants make a single product cover multiple options (color, size, material, capacity, …). On the storefront, customers click variant cards to choose what they want; on the API, you send the chosen options as part of the order.
Variant model
A product has 0+ variant groups. A group has 0+ options. Each option can have:
- A
value(display name) - A
color_code(hex color, only for color-type groups) - A
price_adjustment(added to base price) - An
image_id(linked product image used as the swatch)
Read GET /v1/products/{id} to see all groups and options for a product:
{
"variants": [
{
"id": 11,
"name": "Color",
"type": "color",
"options": [
{ "id": 14, "value": "Red", "color_code": "#ff0000", "image_id": 28, "stock": 12 },
{ "id": 15, "value": "Blue", "color_code": "#0000ff", "image_id": 29, "stock": 5 }
]
},
{
"id": 12,
"name": "Size",
"type": "text",
"options": [
{ "id": 16, "value": "S", "stock": 10 },
{ "id": 17, "value": "M", "stock": 10 },
{ "id": 18, "value": "L", "stock": 5 }
]
}
]
}
A product with 2 colors × 3 sizes has 6 combinations.
How to send variants on POST /v1/orders
Convert the customer's selection into one variant entry per chosen group. For a "Red T-shirt, size L":
"variants": [
{ "group_name": "Color", "option_name": "Red", "color_code": "#ff0000", "price_adjustment": 0 },
{ "group_name": "Size", "option_name": "L", "color_code": null, "price_adjustment": 0 }
]
The names you send are stored exactly as-is on the order — they should match what GET /v1/products/{id} returned. price_adjustment is added to the line price (so final_price = product.price + Σ price_adjustment).
Per-variant stock
If the merchant has enabled variant_stock_enabled on a product, each variant option carries its own stock counter. Read it from options[].stock. The API itself does not block you from creating an order with quantity > stock — that's the merchant's call. Stock is validated and decremented only on pending → confirmed.
Per-combination stock
If combination_stock_enabled is on, stock is tracked per combination (Red+L = 5 units, Red+M = 8, etc.). Combinations are not exposed on the public products endpoint yet (they will be in v1.1 as a combinations[] array on GET /v1/products/{id}/combinations). For now, sub-stock checks happen at confirm-time and are surfaced via the existing dashboard UI.
Cascading variants
The Cascading Variants add-on lets a merchant make Group B's options depend on Group A's selection (e.g. "Brand → Model" — picking "Apple" for Brand only shows "iPhone 15" / "iPhone 14" for Model). The API doesn't enforce cascade rules; your client is expected to know the rules from GET /v1/products/{id} extension data and only send valid combinations. If the merchant has cascading rules and you send an invalid combo, the order still creates (we don't block it) — but the merchant will reject it on confirm.
Image-text variants
Some merchants use the image_text variant type (a thumbnail next to the option label). On the API, you still send group_name + option_name — the image is purely a storefront concern and is not part of the order payload.
Multi-piece offers
If the merchant runs a "Buy 3, mix colors" offer, the customer picks a different variant for each piece. On the API:
"items": [
{ "product_id": 26, "quantity": 1,
"variants": [{ "group_name": "Color", "option_name": "Red" }] },
{ "product_id": 26, "quantity": 1,
"variants": [{ "group_name": "Color", "option_name": "Blue" }] },
{ "product_id": 26, "quantity": 1,
"variants": [{ "group_name": "Color", "option_name": "Green" }] }
]
Three separate line items, each quantity = 1. This way the dashboard order page renders each piece's color cleanly.
Common patterns
"Submit an order from a custom React storefront"
You're building a React/Vue/Next.js storefront that talks to the API instead of using DZBuild's built-in storefront themes. Flow:
- Read
GET /v1/products+GET /v1/products/{id}to render the catalog. - User adds items to a client-side cart.
- Compute
shipping_costfrom the customer'swilaya_id(you can hardcode rates or query aGET /v1/storefor store-wide shipping rates — coming v1.1). - POST
/v1/orderswith the cart + customer block + shipping cost. - Show the customer their
order_numberand a "thank you" page. - Listen for
order.confirmed/order.shippedwebhooks to push notifications back.
See the Custom themes & storefronts guide for a full end-to-end walkthrough.
"Sync new orders to my CRM every minute"
Use the since filter:
curl 'https://api.dzbuild.app/v1/orders?since=2026-04-30T20:00:00Z&limit=200' \
-H "Authorization: Bearer $DZ_KEY"
Even better — register a webhook for order.created and skip polling entirely. See Webhooks.
"Confirm all pending orders for one customer"
PHONE="0555000000"
curl -sS "https://api.dzbuild.app/v1/orders?status=pending&customer_phone=$PHONE" \
-H "Authorization: Bearer $DZ_KEY" \
| jq -r '.data.items[].id' \
| while read OID; do
curl -sS -X PATCH "https://api.dzbuild.app/v1/orders/$OID" \
-H "Authorization: Bearer $DZ_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: confirm-$OID" \
-d '{"status":"confirmed"}'
done