# 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. **Product add-ons (the paid custom-field extras) are not supported by the v1 API** — see [Add-ons](#add-ons-are-not-exposed).
* Has a payment status (`pending`, `paid`, `refunded`) independent of the fulfillment status.

## Lifecycle

```
main path   pending → confirmed → processing → shipped → delivered

legal skips pending → processing        confirmed → shipped

cancel      pending | confirmed | processing → cancelled   (terminal)

return      shipped | delivered → returned                 (terminal)
```

That is the exact transition map the API enforces, per state:

| From         | Allowed next states                    |
| ------------ | -------------------------------------- |
| `pending`    | `confirmed`, `processing`, `cancelled` |
| `confirmed`  | `processing`, `shipped`, `cancelled`   |
| `processing` | `shipped`, `cancelled`                 |
| `shipped`    | `delivered`, `returned`                |
| `delivered`  | `returned`                             |
| `cancelled`  | — (terminal)                           |
| `returned`   | — (terminal)                           |

Cancellation is legal **only** from `pending`, `confirmed` and `processing` — a `shipped` or `delivered` order can no longer be cancelled.

PATCHing an order to the status it already has is a `200` no-op.

| State        | Meaning                                                        | Stock                                                                      |
| ------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `pending`    | Created via storefront or API. Awaiting merchant confirmation. | Not committed, unless the store deducts stock as soon as the order arrives |
| `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 submits orders from its own server instead of using the built-in storefront checkout.

**Auth:** platform key with `orders:write` and `orders:read`. **Requires `Idempotency-Key`.** The answer reads the order back, so a key holding `orders:write` alone gets the order saved and a `403 forbidden` answer, and a retry with the same `Idempotency-Key` replays that `403`.

The order is created in `pending` status. By default stock is **NOT** committed at create time: the first move into a committed state (`confirmed`, `processing`, `shipped` or `delivered`) 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. A store whose **Orders → Display settings → Stock deduction** is set to **As soon as the order arrives** has its stock taken when the API creates the order instead.

### Body

```
{

  "customer": {

    "name":      "Sarra Benali",

    "phone":     "0555000111",

    "email":     "sarra@example.com",

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

      ]

    }

  ],

  "discount":       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            | ✅       | Algerian wilaya code: 1 to 58, or 1 to 69 on a store set to 69 wilayas (read `wilaya_mode` from `GET /v1/shipping/rates`) |
| `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 + 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` \| `pickup` \| `digital` | `home`  | `digital` is for downloadable products only. `pickup` and `digital` carry no delivery charge. When the store has the chosen type turned off for that wilaya and the other one on, the order can switch to the type the store offers; `delivery.type` in the response shows the final type |
| `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 to 9999           |          | Defaults to 1 when omitted                                          |
| `variants`   | array of variant objects |          | See below                                                           |

**Important — server-authoritative pricing.** You do **not** specify the line price. The product's current catalogue price is always used. If you send a `price` field, it is ignored.

Each variant's `price_adjustment` is server-authoritative too: for every `(group_name, option_name)` pair that matches a real option on the product, DZBuild substitutes the catalogue's own `price_adjustment`. Your value survives only for pairs that don't exist in the catalogue — a fail-open for stale integrations — so a negative "discount" you invent is silently discarded for any real option. Treat `price_adjustment` as informational on input: echo the value from `GET /v1/products/{id}` so your client-side total matches the server's.

#### `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. Overwritten by the catalogue value whenever the group/option pair exists on the product — see the pricing note above |

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                                       |         | Ignored if sent. The server quotes delivery from the store's rate for the wilaya and delivery type, its free-shipping rules and the weight surcharge, the same way as an order created in the dashboard. `pickup` and `digital` orders carry no delivery charge. The amount charged is in `amounts.shipping_cost` |
| `discount`       | number ≥ 0                                   | 0       | Promo code amount, manual rebate, etc. Capped at subtotal plus shipping                                                                                                                                                                                                                                           |
| `payment_fee`    | number                                       |         | Ignored if sent; always 0                                                                                                                                                                                                                                                                                         |
| `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` (never below 0), where `shipping_cost` is the server's own quote. `subtotal` itself is `sum(items[].quantity × (price + Σ variants.price_adjustment))`.

Only `discount` is taken from your body. It must be ≥ 0, is capped at the subtotal plus shipping, and is **not** checked against your store's promo codes, so `POST /v1/orders` must only ever be called from a trusted server, never from browser or app code a customer can tamper with.

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

        ]

      }

    ],

    "payment_method": "cod"

  }'
```

### Response 200

Order creation returns HTTP **200**, not 201 — do not branch on the status code; check `data.id` / `data.order_number` instead. The body is 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).

`order_number` has the format `ORD-{store_id}-{YYYYMMDD}-{8 uppercase hex}`. Landing-page orders use `LP-{8 uppercase hex}`. Orders created before 2026-06-02 carry a legacy 4-hex suffix, so a parser must accept both lengths.

### Errors

| Code                                                                            | Cause                                                                                                                           |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `bad_request` "Body must be valid JSON"                                         | Malformed JSON, or a bare JSON value such as a string or a number instead of an object (the Content-Type header is not checked) |
| `bad_request` "customer object is required"                                     | Missing `customer`                                                                                                              |
| `bad_request` "customer.name is required (1-255 chars)"                         | Missing or over-long name                                                                                                       |
| `bad_request` "customer.phone is required (digits, optional leading +)"         | Phone failed regex                                                                                                              |
| `bad_request` "customer.wilaya\_id must be 1-58"                                | Wilaya outside the store's range; a store set to 69 wilayas answers "customer.wilaya\_id must be 1-69"                          |
| `bad_request` "customer.commune is required (1-100 chars)"                      | Missing or over-long commune                                                                                                    |
| `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, pickup, or digital"            | Bad delivery type                                                                                                               |
| `bad_request` "payment\_method must be cod, free\_digital, or digital\_payment" | Bad payment method                                                                                                              |
| `bad_request` "discount must be >= 0"                                           | Negative discount                                                                                                               |
| `bad_request` "Monthly order limit reached for this store plan"                 | **Free** plan cap — see below                                                                                                   |

**Monthly order limit.** The **Free** plan is capped at 30 orders per calendar month; Pro, Unlimited and Enterprise are uncapped. An unrecognised plan name also falls back to the 30/month free cap. The counter is calendar-month and covers **all** order sources (storefront + landing page + dashboard + API).

### Idempotency

Every POST must carry an `Idempotency-Key` header. If you retry the same request (same `Idempotency-Key`, same API key, same body bytes) within 24 h we return the **same** response with `Idempotency-Replay: 1`, so the order is created exactly once. Error answers are replayed too, except `429` and `5xx`, and the same `Idempotency-Key` with a different body answers `422 idempotency_key_reuse`: send a corrected order with a new key. See [Idempotency](https://dzbuild.com/api-docs/idempotency.md).

```
# 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 order is ever created
```

### Webhook fired

Creating an order fires `order.created` to all `/v1/webhooks` subscriptions for that event. See [Webhook events](https://dzbuild.com/api-docs/webhooks/events.md).

`/v1/webhooks` only sees API activity

`/v1/webhooks` subscriptions fire **only for changes made through the v1 API**. Orders placed on the storefront or a landing page, and status changes made in the dashboard, do **not** fire `order.created` / `order.confirmed` / `order.shipped`.

For events covering *all* order activity, use the **Webhooks addon** (`/dashboard/addons` → "Webhooks — Connect n8n, Make & Zapier", **requires the Unlimited plan**), which picks up every order regardless of where it came from — or keep polling `GET /v1/orders?since=…`.

### What the API order path skips

An order created through the API is a lean insert. Compared with a storefront, landing-page or dashboard order, it does **not**:

* Produce any Meta / TikTok pixel or CAPI event.
* Increment the customer's `total_orders` / `total_spent` counters.
* Check the customer blacklist — a banned customer's API order goes through. Read `is_banned` from [Customers](https://dzbuild.com/api-docs/resources/customers.md) and reject client-side if you need that.
* Apply the storefront checkout's automated abuse checks, or the Min/Max Quantity addon rules.

The merchant does get the usual new-order notification, the same one a storefront order sends.

Also note that a customer *created* by the API stores the whole `customer.name` string in `first_name` and leaves `last_name` empty; for an existing customer matched by phone, only `first_name` is overwritten and any existing `last_name` is left untouched.

***

## `GET /v1/orders`

List orders. Cursor-paginated.

**Auth:** key with `orders:read` (part of the default scopes). A key without it gets `403 forbidden` "Missing scope: 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           |

An invalid `status` or an unparseable `since` is silently ignored — you get the unfiltered list, not a `400`. There is no `payment_status` filter at v1; filter client-side.

### 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-AD3C91F7",

        "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:** key with `orders:read`. A key without it gets `403 forbidden`.

### Response 200

```
{

  "data": {

    "id":             6894,

    "order_number":   "ORD-13-20260317-AD3C91F7",

    "store_seq":      644,

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

    "shipment": {

      "sent_to_delivery":    true,

      "sent_to_delivery_at": "2026-09-06 16:04:30",

      "delivery_company":    "colivraison",

      "delivery_tracking":   "TRK-0000000000",

      "last_send_failure":   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": [

          { "order_item_id": 8421, "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"

  }

}
```

### Shipment fields

`store_seq` is the order number the merchant sees in the dashboard (`#644`); an API create numbers the order before it answers, and in the rare case numbering fails it reads null until it is filled shortly after. `shipment.sent_to_delivery` turns true once a courier accepted the parcel; `delivery_company` is the courier slug and `delivery_tracking` its tracking number. `shipment.last_send_failure` is the most recent refused send for this order, `{ "at", "provider", "message" }` with the courier's own message, or null when no send failed. It is always null once `sent_to_delivery` is true, so an earlier failure stops showing after a successful send. The same object is returned by create, update and cancel.

### Variants in the response

The `variants` array on each item is the source of truth for what the customer picked. Each entry has `order_item_id`, `group_name`, `option_name`, `color_code` (for color variants), and `price_adjustment` (the per-piece add-on price, returned as a JSON **string**, not a number). For multi-piece offers, you may see multiple variant entries on the same item with different effective groupings — see the per-piece notes below.

### What the detail response does not return

* `notes` is **write-only** at v1 — it is stored on the order and shown in the dashboard, but never returned by `GET /v1/orders/{id}`.
* The item's `product_name`, `sku` and line `total` are captured at create time but are not returned either. Join back to `GET /v1/products/{id}` if you need names.

### Add-ons are not exposed

Product add-ons (the paid custom-field extras a merchant configures on a product) have no v1 surface: you cannot send them on `POST /v1/orders`, and `GET /v1/orders/{id}` returns no add-on entries. An order placed on the storefront **with** add-ons reads back through the API with the add-on money already baked into `items[].price` (and therefore `amounts.subtotal`) — but with no add-on line, title or file attribution.

***

## `PATCH /v1/orders/{id}` — change status

**Auth:** platform key with `orders:write` and `orders:read`. **Requires `Idempotency-Key`.** Without `orders:read` the status still changes, but the answer is `403 forbidden`.

Body must be `{"status": "<one of the 7>"}`. We validate the transition against the table in [Lifecycle](#lifecycle); otherwise we return `400` with the legal next states. PATCHing to the order's current status is a `200` no-op.

### 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. Concurrent transitions on the same order are serialised: if another request changed the status first, yours fails cleanly with `bad_request` ("order status changed concurrently; retry") instead of applying twice.

### Stock side-effects

The **committed-stock** states are `confirmed`, `processing`, `shipped` and `delivered`.

* **Non-committed → committed** — stock is **decremented** the first time the order enters any of those states, including the `pending → processing` shortcut. The product's sales count goes up too.
* On a store whose **Stock deduction** is set to **As soon as the order arrives**, stock was already taken when the order was created, so this move takes nothing more.
* **Committed → cancelled / returned** — stock is **restored**.
* Other transitions don't touch stock.

Stock is **not validated**: the decrement clamps at zero, so overselling is never rejected, and a stock problem never fails or reverts the status change. Stock behaves exactly as it does for orders managed from the dashboard.

### Errors

| Code                                                                          | Cause                                                                                                                                                                                                |
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bad_request` "Field "status" is required"                                    | Body omitted `status`                                                                                                                                                                                |
| `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 (the API emits a plain ASCII `->`)                                                                                                                                  |
| `bad_request` "order status changed concurrently; retry"                      | Another request changed status while you were transitioning. Retry with a **new** `Idempotency-Key`: this 400 is stored under the key you sent, and a retry with it replays the same error for 24 h. |
| `not_found`                                                                   | Order id is unknown or belongs to another store                                                                                                                                                      |

***

## `POST /v1/orders/{id}/cancel`

Convenience endpoint — **exactly** equivalent to `PATCH /v1/orders/{id}` with `{"status":"cancelled"}`. Same transition table, no extra permission. Cancellation is legal only from `pending`, `confirmed` and `processing`; from `shipped` or `delivered` you get `400 bad_request` (those states can only go to `delivered` / `returned`).

**Auth:** platform key with `orders:write` and `orders:read`. **Requires `Idempotency-Key`.** Without `orders:read` the order is still cancelled, but the answer is `403 forbidden`.

```
curl -X POST 'https://api.dzbuild.app/v1/orders/6894/cancel' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: cancel-6894-$(date +%s)"
```

***

## `POST /v1/orders/{id}/send-to-delivery`

Hands the order to the store's courier, the same send as the button in the dashboard. A parcel cannot be recalled once the courier has it, so every send takes two calls and the merchant approves the first one.

**Auth:** key with `delivery:send`. **Requires `Idempotency-Key`.** Keys created at **Settings → API** do not carry this scope and get `403 forbidden`; an app gets it when the merchant approves it at install.

| Field           | Type             | Notes                                                                                                                       |
| --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `provider`      | string, optional | Slug of a courier linked to the store. Omit it to use the store's default courier. A courier that is not linked is refused. |
| `confirm_token` | string           | The token from the first call, sent once the merchant approves                                                              |

1. Call without `confirm_token`. The answer is `422 confirmation_required` with `confirm_token` (single use, valid `600` seconds), `action` and `will_change`: customer, phone, destination, delivery type, total and courier. Show that summary to the merchant.
2. Once they approve, repeat the call with the same `provider`, the `confirm_token` and a **new** `Idempotency-Key` (the first key is bound to the body without the token). A token that expired, was already used, or no longer matches the order answers `422 confirmation_stale` with a fresh token. A `confirm: true` flag is not accepted here.

The send normally runs in the background and answers `202` with `queued: true`. Read `GET /v1/orders/{id}` until `shipment.sent_to_delivery` is true with a `delivery_tracking`, or until `shipment.last_send_failure` shows the courier's refusal. If the background runner is unavailable the send happens inside the request instead: `200` with `sent`, `provider` and `tracking`, or `422 courier_refused` with the courier's message. Once the courier accepts the parcel, a `pending` or `confirmed` order moves to `processing`, which takes the stock if it was not taken yet. Orders with delivery type `pickup` are never handed to a courier. A background send stopped before the courier is called, such as one for a `pickup` order or for a courier that is not linked, still answers `202` and changes neither `shipment` field.

| Code                                                | Cause                                                                                                                  |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `forbidden` (403)                                   | The key lacks `delivery:send`                                                                                          |
| `not_found` (404)                                   | Unknown order, or another store's order                                                                                |
| `already_sent` (409)                                | The order is already with a courier; `error.tracking` holds its tracking number                                        |
| `confirmation_required`, `confirmation_stale` (422) | See the two steps above                                                                                                |
| `courier_refused` (422)                             | The courier refused the parcel during an in-request send                                                               |
| `rate_limited`, `too_many_concurrent` (429)         | Courier calls have their own per-store budget on top of the [rate limits](https://dzbuild.com/api-docs/rate-limits.md) |

```
curl -X POST 'https://api.dzbuild.app/v1/orders/6894/send-to-delivery' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: send-6894-approved" \

  -d '{"confirm_token": "cft_REPLACE_WITH_TOKEN"}'
```

***

## 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`), but for any pair that exists in the catalogue the server substitutes its own value, so send the catalogue's number.

### 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 decremented on the first move into a committed state (`confirmed`, `processing`, `shipped`, `delivered`), or at create on a store that deducts stock as soon as the order arrives, and is never validated: the decrement clamps at zero, so an oversell is recorded rather than rejected.

### Per-combination stock

If `combination_stock_enabled` is on, stock is tracked per **combination** (Red+L = 5 units, Red+M = 8, etc.). `GET /v1/products/{id}` returns them in `combinations[]` (each with `id`, `sku`, `stock`, `is_active` and its `options`), with `combination_count`; the list stops at 300 entries and `combinations_truncated` tells you when it was cut. See [Products](https://dzbuild.com/api-docs/resources/products.md). Combination stock moves at the same moment as the rest of the order's stock.

### 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 add-on requires the Pro plan.

`GET /v1/products/{id}` exposes **no** cascade information — the parent/child mapping is only applied on the storefront and in the dashboard. Your client cannot discover cascade rules through the API at v1: hard-code them, or read them from the dashboard. Sending an invalid combination still creates the order (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:

1. Read `GET /v1/products` + `GET /v1/products/{id}` to render the catalog.
2. User adds items to a client-side cart.
3. To show a delivery price before checkout, read the store's rates with `GET /v1/shipping/rates` (scope `shipping:read`). The order itself is always charged the server's own quote.
4. POST `/v1/orders` with the cart and the customer block; `amounts.shipping_cost` in the response is the delivery cost charged.
5. Show the customer their `order_number` and a "thank you" page.
6. Poll `GET /v1/orders?since=…` for fulfilment progress. `/v1/webhooks` `order.confirmed` / `order.shipped` events fire only when the status was changed **through the API** — a merchant confirming in the dashboard produces no event.

See the [Custom themes & storefronts](https://dzbuild.com/api-docs/guides/custom-storefronts.md) 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"
```

Polling is the right tool here. A `/v1/webhooks` subscription on `order.created` only covers orders your own integration created through the API — storefront and landing-page orders never fire it, so it cannot replace the `since` sweep. See [Webhooks](https://dzbuild.com/api-docs/webhooks/overview.md).

### "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
```
