# Custom themes & storefronts

DZBuild ships several ready-made storefront themes and a no-code customizer. But if you want **full control** — your own React/Vue/Next.js/Flutter front-end, your own pixel-perfect design, your own routing — the API is built for you.

This guide walks through building a headless storefront end-to-end:

1. Render the catalog (products, categories, variants)
2. Build a cart (client-side or server-side, your call)
3. Submit the order via API
4. Receive webhooks when status changes

By the end, your custom storefront will be 100% interoperable with the merchant's DZBuild dashboard — orders show up, stock decrements, shipping integrates with their courier setup, no compromises.

## Architecture

```
┌──────────────────────┐         ┌──────────────────────────┐

│ Custom storefront    │  HTTPS  │  api.dzbuild.app/v1/*    │

│ (React, Vue, Flutter)│ ──────► │  Authorization: Bearer … │

│                      │         │                          │

│ - Reads products     │ ◄────── │  JSON responses          │

│ - Renders cart       │         │                          │

│ - Submits orders     │         └──────────────────────────┘

└──────────────────────┘                      │

                                              ▼

                                  Merchant DZBuild dashboard

                                  - Confirms orders

                                  - Manages stock

                                  - Integrates with couriers
```

You own the UI. DZBuild owns the data and the operations. The merchant logs into `dzbuild.com/dashboard` to manage orders confirmed in your custom UI.

## Prerequisites

* A DZBuild store on an active **Enterprise** plan. The API is an Enterprise feature: a key whose store is on another plan, or whose Enterprise subscription has expired, gets `403 forbidden` `"API access requires an active Enterprise plan"` on every call, and works again once the store is back on an active Enterprise plan.
* **An API key generated by the store owner** at **Settings → API** in the dashboard. Keys made there are already enrolled in the pilot. A key that is not enrolled gets `403 forbidden` `"API is in pilot mode; key not enrolled"`.
* A back-end (or serverless function / edge worker) that holds the API key. **Never** ship the secret to the browser — see [Security](#security).

The plan also sets a monthly order cap: a **Free-plan store stops accepting orders after 30 in a calendar month**, and `POST /v1/orders` then returns `400 bad_request "Monthly order limit reached for this store plan"`. Pro, Unlimited and Enterprise have no order cap. Handle that error in your checkout.

## Step 0 — get a key

The store owner generates keys in the merchant dashboard at **Settings → API** (`/dashboard/api`), on an active Enterprise plan. A store holds up to 3 active keys of its own, and each secret is shown once:

1. **Generate the first key** at Settings → API. Keys made there are already enrolled in the pilot.
2. **Mint any further keys yourself** from that one, or generate them on the same page:

```
curl -X POST 'https://api.dzbuild.app/v1/keys' \

  -H "Authorization: Bearer $DZ_KEY" \

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

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{"type":"platform","name":"Custom storefront — production"}'

# HTTP 200 → { "data": { "key_id", "bearer_token", "signing_secret", "note" } }
```

The new key copies the calling key's rate-limit tier and pilot flag. **Scopes are not selectable**: a new platform key gets the default set (`store:read`, `store:write`, `products:read`, `products:write`, `orders:read`, `orders:write`, `customers:read`, `landing_pages:read`, `landing_pages:write`, `promos:read`, `promos:write`, `pixels:read`, `pixels:write`, `shipping:read`, `shipping:write`, `webhooks:read`, `webhooks:write`, `usage:read`, `analytics:read`, `whatsapp:read`, `whatsapp:send`), limited to the scopes the calling key holds, so isolate environments with separate keys, not with narrower permissions.

Copy the `bearer_token` — it's shown **once** at creation. Lost it? Revoke and mint a new one.

Test with:

```
curl https://api.dzbuild.app/v1/whoami \

  -H "Authorization: Bearer $DZ_KEY"

# expect: { "data": { "key_id": "...", "store_id": 13, "type": "platform", "scopes": [...] } }
```

## Step 1 — render the catalog

```
// pages/index.js (Next.js)

export async function getServerSideProps() {

  const res = await fetch('https://api.dzbuild.app/v1/products?limit=50&status=active', {

    headers: { 'Authorization': `Bearer ${process.env.DZ_KEY}` },

  });

  const { data } = await res.json();

  return { props: { products: data.items } };

}



export default function Home({ products }) {

  return (

    <ul>

      {products.map(p => (

        <li key={p.id}>

          {p.primary_image && (

            <img src={p.primary_image} alt={p.name} />

          )}

          <h2>{p.name}</h2>

          <p>{p.price} DZD</p>

          <a href={`/product/${p.slug}`}>View</a>

        </li>

      ))}

    </ul>

  );

}
```

Two things about images:

* `primary_image` is a full CDN URL (for example `https://cdn.dzbuild.app/uploads/products/<store_id>/<file>`). Put it in the `img` tag as it is, without adding a prefix.
* The list item does **not** include `store_id`. Read it once from `GET /v1/store` or `GET /v1/whoami` and keep it in config.
* `primary_image` is `null` when a product has no image. Guard for it, as above.

Cache the response on your side: the products list is served fresh on every call, so repeated reads at scale cost a round trip each.

## Step 2 — render a product detail page with variants

```
const res = await fetch(`https://api.dzbuild.app/v1/products/${id}`, {

  headers: { 'Authorization': `Bearer ${process.env.DZ_KEY}` },

});

const { data: product } = await res.json();
```

The response includes `variants[]` — each entry is a variant group with options:

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

    ]

  }

]
```

Render one picker per group. For `type: color`, render swatches with the `color_code`; for `type: text`, render labels; for `type: dropdown`, render a select; for `type: image_text`, render thumbnails: `options[].image_id` is a **numeric id** that matches an entry's `images[].id` in the same product response. A `type: selectable` group is optional and lets the customer pick several options. Each option also carries `price_adjustment` (the amount added to the product price, used in Step 3) and `is_active`; hide options where `is_active` is `false`.

`images[].url` is a full CDN URL, like `primary_image` in Step 1, so put it in an `img` tag as it is.

**Out-of-stock handling:** `options[].stock` is `null` if the merchant doesn't track per-variant stock. If it's a number ≤ 0, grey out that option. The API does not refuse an order for an out-of-stock option, so check stock in your UI before checkout. Per-combination stock is in `combinations[]` of the same response.

## Step 3 — build a cart

Cart is **client-side** (React state, Vuex, Pinia, localStorage, …). You don't need an API call to add to cart. Each cart line is:

```
{

  product_id:   26,

  product_name: "T-shirt",   // for display

  base_price:   1500,

  quantity:     1,

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

  ]

}
```

Compute the line total client-side: `(base_price + sum(price_adjustment)) × quantity`. Show the customer the cart total, but treat it as display only:

* **Any price you send in the item is ignored** (the API field is `price`). The server always uses the catalog price for `product_id`.
* **`price_adjustment` is re-resolved from the catalog** by the `(group_name, option_name)` pair. On a mismatch the catalog value wins. Only when the pair doesn't resolve at all does your value get used — which is exactly what happens after a merchant renames a variant option, so keep your `group_name`/`option_name` strings in sync with the catalog.
* `group_name` and `option_name` are silently truncated to 100 characters, and `color_code` to 7.

## Step 4 — collect customer info + compute shipping

Standard checkout form: name, phone, wilaya, commune, address. Read the wilaya list once from `GET /v1/wilayas` (names in Arabic, French and English) and the communes of a wilaya from `GET /v1/wilayas/{id}/communes`. The list has 58 wilayas, or 69 on a store set to 69 wilayas, and that store's orders accept wilaya ids up to 69.

`GET /v1/store` is live and worth calling once at boot: it returns the store's name, slug, language, description, logo, favicon, banner, theme colours and font, subdomain, custom domain (and whether it's verified), `public_url`, `hide_branding` and `created_at`. It is served fresh on every call.

Shipping is not in `GET /v1/store`; it has its own endpoints: `GET /v1/shipping/rates` returns the merchant's home and desk price per wilaya, `GET /v1/shipping/settings` the free-shipping rules, and `GET /v1/shipping/coverage?wilaya_id=16` the stop desks of the store's courier for that wilaya (see the stop-desk picker below). Use them to show the customer an estimate before checkout.

Do not send a shipping price: `POST /v1/orders` computes it from the merchant's rates for the wilaya and delivery type, applies free shipping and the weight surcharge, and ignores any `shipping_cost` in the body. If the chosen delivery type is turned off for that wilaya and the other one is on, the order switches to the one the merchant offers. `pickup` and `digital` orders carry no shipping charge. Show the customer `amounts.shipping_cost` and `amounts.total` from the reply.

## Step 5 — submit the order

```
// On your back-end (Next.js API route, Edge function, server, …)

async function placeOrder(req, res) {

  const cart = req.body;



  const order = {

    customer: {

      name:      cart.name,

      phone:     cart.phone,

      email:     cart.email || null,

      wilaya_id: cart.wilaya_id,

      commune:   cart.commune,

      address:   cart.address || ''

    },

    delivery: {

      type:      cart.delivery_type,           // "home" | "desk" | "pickup" | "digital"

      desk_id:   cart.desk_id || null,

      desk_name: cart.desk_name || null

    },

    items: cart.items.map(line => ({

      product_id: line.product_id,

      quantity:   line.quantity,

      variants:   line.selected_variants

    })),

    // no shipping_cost: the server computes shipping from the merchant's rates

    discount:       0,

    payment_method: 'cod',

    notes:          cart.notes || null

  };



  const idempKey = req.headers['x-checkout-id'] || crypto.randomUUID();



  const apiRes = await fetch('https://api.dzbuild.app/v1/orders', {

    method: 'POST',

    headers: {

      'Authorization':    `Bearer ${process.env.DZ_KEY}`,

      'Content-Type':     'application/json',

      'Idempotency-Key':  idempKey

    },

    body: JSON.stringify(order)

  });



  if (!apiRes.ok) {

    const err = await apiRes.json();

    return res.status(apiRes.status).json(err);

  }

  const { data: createdOrder } = await apiRes.json();   // HTTP 200, not 201

  return res.json({

    order_number: createdOrder.order_number,

    total:        createdOrder.amounts.total

  });

}
```

`POST /v1/orders` answers **HTTP 200** on success, and the body is the full canonical order detail, the same shape `GET /v1/orders/{id}` returns. Branch on `apiRes.ok`, never on `status === 201`.

The customer sees their `order_number` on the success page. The merchant's dashboard now shows the new order in the `pending` list, ready to confirm.

### Validation limits

Every rule below throws `400 bad_request` with the message inline, so surface them in your checkout form rather than discovering them in production:

| Field                | Rule                                                                                                                                                           |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `items`              | 1–50 lines, non-empty array                                                                                                                                    |
| `items[].product_id` | Must belong to the key's store                                                                                                                                 |
| `items[].quantity`   | 1–9999                                                                                                                                                         |
| `customer.name`      | 1–255 characters                                                                                                                                               |
| `customer.phone`     | Must match `^\+?[0-9 ]{6,20}$` — digits and spaces only, with at most one leading `+`. **Dashes and parentheses are rejected**, so normalize before you submit |
| `customer.wilaya_id` | 1 to 58, or 1 to 69 on a store set to 69 wilayas                                                                                                               |
| `customer.commune`   | 1–100 characters                                                                                                                                               |
| `delivery.type`      | `home`, `desk`, `pickup` or `digital` (defaults to `home`)                                                                                                     |
| `payment_method`     | `cod`, `free_digital` or `digital_payment` — nothing else (`card`, `paypal`, … are rejected)                                                                   |
| `discount`           | Must be `>= 0`. Capped at subtotal plus shipping (not an error). Any `shipping_cost` or `payment_fee` you send is ignored                                      |
| `notes`              | Truncated to 1000 characters (not an error)                                                                                                                    |

Plus the plan cap: on a Free-plan store, order 31 in a calendar month returns `400 bad_request "Monthly order limit reached for this store plan"`.

## Step 6 — receive webhooks (optional but powerful)

Register a webhook so your storefront can react to order events:

```
curl -X POST 'https://api.dzbuild.app/v1/webhooks' \

  -H "Authorization: Bearer $DZ_KEY" \

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

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "url":    "https://yourstorefront.com/api/dz-webhook",

    "events": ["order.created", "order.confirmed", "order.shipped", "order.delivered"]

  }'
```

You'll receive a `secret` in the response (HTTP 200): store it. Every delivery is signed with it, so check `X-DZ-Signature` before you act on a delivery. See [Verifying signatures](https://dzbuild.com/api-docs/webhooks/verifying-signatures.md) for the recipe and code.

Two limits worth knowing before you build on this: API v1 `order.created` fires **only** for orders your storefront creates through the API (not for anything placed in the merchant's own DZBuild storefront), and status events fire only for status changes made through the API. See the [Event catalog](https://dzbuild.com/api-docs/webhooks/events.md).

Use webhooks to:

* Send the customer an SMS when their order is confirmed.
* Update your CRM / Google Sheet / analytics.
* Invalidate a "thank you page" once the merchant confirms.
* Trigger downloadable-product delivery once `order.delivered` fires.

## Common patterns

### Multi-language storefront

Keep your translations in your front-end. The API returns product names + descriptions exactly as the merchant entered them. If the merchant uses the [Multi-language add-on](https://dzbuild.com/docs/addons/multi-language.md), `GET /v1/products/{id}` still returns only the canonical name and description; the add-on's translations are not part of the API response, so pick what your customer wants client-side.

### Stop-desk picker

For `delivery.type = "desk"`:

```
// 1. Read the stop desks of the store's courier for the chosen wilaya:

//    GET /v1/shipping/coverage?wilaya_id=16  (scope shipping:read)

//    desks[] holds desk_id, name, address, phone, commune_id;

//    422 no_courier_linked means the store has no courier linked



// 2. Show the customer a list, let them pick:

selectedDesk = { id: 7842, name: "Yalidine Bab Ezzouar" };



// 3. Pass to the order:

order.delivery = {

  type:      "desk",

  desk_id:    selectedDesk.id,

  desk_name:  selectedDesk.name

};
```

### Online payment (SlickPay / Edahabia)

For `payment_method = "digital_payment"`:

1. Submit the order via API as usual; it's created in `pending` with `payment_status = pending`.
2. Redirect the customer to your SlickPay / Edahabia checkout URL.
3. On success, your back-end receives the payment provider's confirmation. You can then move the order on with `PATCH /v1/orders/{id}` and `{"status": "confirmed"}`. `payment_status` cannot be set through the API, and the store's own SlickPay setup only marks orders paid that were paid at the store's own checkout, so it does not mark an order created through the API as paid.

### Returns & refunds

Currently handled in the dashboard. Through the API you can mark an order returned with `PATCH /v1/orders/{id}` and `{"status": "returned"}`, allowed from `shipped` or `delivered`. A refund endpoint is not available.

## Security

* **Never** put the API key in browser-facing code. Keys belong on your server / serverless function / edge worker. The browser calls **your** endpoint, your endpoint calls DZBuild.
* **HTTPS only** between your storefront and `api.dzbuild.app`. Plain HTTP is rejected.
* **Idempotency-Key required** on every `POST`, `PATCH` and `DELETE` (optional on `PUT`). Without it you get `400` with code `bad_request` and the message `"Idempotency-Key header is required for write requests"`. The code is `bad_request`, not `idempotency_key_required`. The value must be ≤ 64 characters from `[A-Za-z0-9_-:.]`; `crypto.randomUUID()` passes, but base64 and most hash encodings don't (`+`, `/`, `=` are rejected).
* **Rate limits** are counted per **store**: every key of the store shares one budget of 600 requests per minute, so extra keys do not raise it. Only the Enterprise plan has API access, and a store that leaves it gets `403` until it is back on an active Enterprise plan. Note these limits apply only to API calls; the storefront you build serves your shoppers without consuming them. See [Rate limits](https://dzbuild.com/api-docs/rate-limits.md) for the current caps.
* **One key per environment.** Don't share dev and prod keys. Mint a separate key for staging; revoke it when staging closes. (You cannot choose scopes, and a key keeps the scopes it was minted with, so separation comes from the key itself.)

## Troubleshooting

| Problem                                                             | Likely cause                                                                              | Fix                                                                     |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `401 unauthorized "Missing Authorization header"`                   | Forgot the `Authorization: Bearer …` header                                               | Add it                                                                  |
| `401 unauthorized "Invalid bearer format"`                          | Token has no dot — usually a truncated copy-paste that kept only the `dzpk_live_…` key id | Use the full 73-character bearer token                                  |
| `401 unauthorized "Invalid or revoked API key"`                     | Typo, revoked or expired key, or wrong env var                                            | Generate a new key at **Settings → API**                                |
| `403 forbidden "API is in pilot mode; key not enrolled"`            | Key exists but DZBuild hasn't enrolled it in the pilot                                    | Contact support                                                         |
| `403 forbidden "API access requires an active Enterprise plan"`     | The store is not on Enterprise, or its Enterprise subscription expired                    | The merchant renews or upgrades to Enterprise; the same key works again |
| `400 bad_request "Product N does not belong to this store"`         | Cross-store id (you're using a key for store A but sending product from store B)          | Use the correct key                                                     |
| `400 bad_request "items must be a non-empty array"`                 | Empty cart                                                                                | Don't submit empty carts                                                |
| `400 bad_request "Monthly order limit reached for this store plan"` | Free-plan store hit its 30-orders-per-month cap                                           | Merchant upgrades to Pro or above                                       |
| `429 rate_limited`                                                  | You're polling too aggressively                                                           | Switch to webhooks; back off using the `Retry-After` header             |

## Example code

Everything you need is on this page — catalog listing, variant rendering, cart shape, order submission, webhook registration. Copy from the sections above; there are no separate starter repositories to clone.

## Going live

1. Test thoroughly with a `pilot` key on a test store.
2. Mint a separate key for production (same scopes, different name).
3. Deploy your storefront with the prod key in env vars.
4. Place a real test order; confirm it appears in the dashboard.
5. Place a refund test if you offer them.
6. Register your webhooks pointing to your production URL.
7. Monitor `GET /v1/usage` daily for the first week to spot anomalies.

## Roadmap

| Feature                                           | Status                                                                              |
| ------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Wilaya shipping rates and stop desks              | Live: `GET /v1/shipping/rates` and `GET /v1/shipping/coverage?wilaya_id=`           |
| Per-combination stock                             | Live: `combinations[]` on `GET /v1/products/{id}` and `GET /v1/products/{id}/stock` |
| `POST /v1/orders/{id}/refund` for refunds via API | Not available                                                                       |
| Product image upload                              | Live: `POST /v1/products/{id}/images` takes a public https image URL                |
| Multi-language fields on product responses        | Not available                                                                       |

If you need any of these sooner, [contact support](https://dzbuild.com/docs/operations/faq.md).
