Skip to main content

Custom themes & storefronts

Build a fully custom storefront in React, Vue, Next.js, Flutter, or any stack — backed by DZBuild's API.

Written by Support

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.

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

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

Did this answer your question?