# DZBuild Developers > Developer documentation for DZBuild, the e-commerce platform for Algerian merchants. Third-party apps use OAuth 2.0 with PKCE to get one install token per store, call the REST API at https://api.dzbuild.app/v1 and receive webhooks signed with X-DZ-Signature. - [DZBuild Developers](https://dzbuild.dev/index.md) ## api-reference - [API reference](https://dzbuild.dev/api-reference.md): Where to find the DZBuild REST API reference in English, Arabic and French, and how to download the OpenAPI description for your tools. ## build-with-ai - [Build with AI agents](https://dzbuild.dev/build-with-ai.md): Use Claude Code, Codex, Cursor, ChatGPT or any coding agent to build a DZBuild app. Where the Markdown pages, llms.txt, the agent skill and the OpenAPI description live, and how to keep secrets out of the chat. ## changelog - [Changelog](https://dzbuild.dev/changelog.md): Dated changes to the DZBuild developer platform: new scopes, endpoints, webhook events and any change that affects apps already installed. ## concepts - [Core concepts](https://dzbuild.dev/concepts.md): How DZBuild apps work: apps, installs, stores, plans, the min_plan setting and the per-install rate limit that every app token shares. ## errors - [Errors](https://dzbuild.dev/errors.md): Every error a DZBuild app can meet, in one page. The API error envelope, the 400 to 502 codes with their messages, OAuth authorize and token errors, WhatsApp codes and webhook delivery failures. ## faq - [Frequently asked questions](https://dzbuild.dev/faq.md): Short answers about building apps for DZBuild stores. What an app is, who can build one, plans, token expiry, local testing, WhatsApp messages, webhook events, the review and AI-assisted development. ## getting-started - [Get started](https://dzbuild.dev/getting-started.md): Create your first DZBuild app: register it in the developer console, choose scopes, install it on your own store and make a first API call. ## home-layout - [Home page sections](https://dzbuild.dev/home-layout.md): Read and change a store's home page sections from your app, with the ten section types, the six endpoints, plan limits, undo and error codes. ## intro - [DZBuild developer documentation](https://dzbuild.dev/intro.md): Build apps for DZBuild stores: register an OAuth app, install it on a merchant store, call the REST API and receive signed webhooks. ## oauth - [OAuth install flow](https://dzbuild.dev/oauth.md): The DZBuild OAuth flow for apps: authorize parameters, merchant consent, PKCE with S256, the token exchange and every error code it returns. ## rate-limits - [Rate limits](https://dzbuild.dev/rate-limits.md): Rate limits for DZBuild app tokens: the per-install bucket, the store bucket, the Idempotency-Key header and the 402 and 429 responses. ## review-guidelines - [Review guidelines](https://dzbuild.dev/review-guidelines.md): What DZBuild checks before it approves an app, listed or unlisted, and how to prepare your app, listing and support details for the review. ## scopes - [Scopes](https://dzbuild.dev/scopes.md): The scopes a DZBuild app can request, what each one grants on the REST API, and how the merchant sees them on the consent screen. ## security - [Security requirements](https://dzbuild.dev/security.md): Security rules every DZBuild app must follow: exact redirect URIs, a server-side client secret, verified webhooks and deleting store data. ## webhooks - [Webhooks](https://dzbuild.dev/webhooks.md): Receive signed DZBuild webhooks in your app: the event list, the X-DZ-Signature header, the replay window and verification code samples. ## whatsapp - [WhatsApp API](https://dzbuild.dev/whatsapp.md): Send the platform-approved WhatsApp order templates from your app, paid from the store wallet, and read the templates, balance and messages. --- # Full Documentation Content # API reference Your app calls the same REST API that merchants use, at `https://api.dzbuild.app/v1`. Two references describe it, and this page covers what changes when you call it with an install token. If you do not yet have an install token, follow [Get started](https://dzbuild.dev/getting-started.md) first. For each call you add to your app: 1. Find the operation in the app OpenAPI description linked below. An endpoint appearing in the merchant documentation alone does not mean apps can call it. 2. Check its required [scopes](https://dzbuild.dev/scopes.md) and use the token for the store the merchant approved. 3. Follow the response and pagination rules below, then check [Rate limits](https://dzbuild.dev/rate-limits.md) for request budgets, retries and idempotency requirements. For a catalog export, look up the product-list operation, request `products:read` and follow pagination until there are no more products to export. ## Two references[​](#two-references "Direct link to Two references") **The API documentation on dzbuild.com** explains the main resources with their parameters, responses and examples, in three languages: * English: [dzbuild.com/api-docs](https://dzbuild.com/api-docs/intro) * Arabic: [dzbuild.com/ar/api-docs](https://dzbuild.com/ar/api-docs/intro) * French: [dzbuild.com/fr/api-docs](https://dzbuild.com/fr/api-docs/intro) Those pages are written for merchant API keys. With an install token, the rules on this page win where they differ: any plan can use your app, the limits are in [Rate limits](https://dzbuild.dev/rate-limits.md), and some endpoints are closed to apps. **The OpenAPI description for apps** is one JSON file in OpenAPI 3.1 format. It lists the 92 operations an install token can call, with their scopes, parameters and response shapes. Import it into Postman or Insomnia, or generate types from it. [Download dzbuild-apps-v1.json](https://dzbuild.dev/openapi/dzbuild-apps-v1.json) ``` curl -sO https://dzbuild.dev/openapi/dzbuild-apps-v1.json ``` The file declares `https://api.dzbuild.app` as its server. Its `dzOAuth` security scheme lists every scope with an English description. ## Sending the install token[​](#sending-the-install-token "Direct link to Sending the install token") The token exchange gives you one access token per store the merchant approved (see [OAuth](https://dzbuild.dev/oauth.md)). Each token starts with `dzpk_live_`. Send it in the `Authorization` header with the `Bearer` scheme: ``` curl https://api.dzbuild.app/v1/whoami \ -H "Authorization: Bearer dzpk_live_xxxxxxxx" ``` The token decides the store. There is no store parameter on the requests, so to work on another store of the same merchant, use that store's token from the `stores` list of the token response. Keep tokens on your server. They do not expire on their own. A token stops working when the merchant uninstalls your app, or when the merchant installs it again on the same store, which replaces the token. | Status | Message | Cause | | ------ | ---------------------------------- | ------------------------------------------------------ | | `401` | `Missing Authorization header` | No `Authorization` header. | | `401` | `Unsupported Authorization scheme` | The header does not start with `Bearer`. | | `401` | `Invalid or revoked API key` | The token is wrong, or it was revoked by an uninstall. | Every install token is also checked against the state of your app and of the store. A refused call answers `403` with one of these codes: | Code | Meaning | | ------------------- | ---------------------------------------------------------------- | | `app_uninstalled` | The app is no longer installed on this store. | | `app_suspended` | DZBuild suspended the app. | | `app_not_approved` | The app is in test mode and only runs on its developer's stores. | | `app_plan_required` | The store's plan is below the minimum plan you set for the app. | ## Responses[​](#responses "Direct link to Responses") A successful response puts the result in `data`. An error puts a `code` and a `message` in `error`. Both carry `meta.request_id`; quote it when you contact DZBuild about a call. ``` { "error": { "code": "forbidden", "message": "Missing scope: orders:write" }, "meta": { "request_id": "8f2c1a9d4b7e6035", "api_version": "v1" } } ``` List endpoints answer: ``` { "data": { "items": [...], "next_cursor": "...", "has_more": true }, "meta": { "request_id": "...", "api_version": "v1" } } ``` Send `next_cursor` back as `cursor` to get the next page, and stop when `has_more` is `false`. `GET /v1/orders` also accepts `since` (orders created at or after that time), `status` and `customer_phone`. ## whoami[​](#whoami "Direct link to whoami") `GET /v1/whoami` needs no scope. It tells you which store a token belongs to and what it may do. For an install token it adds an `app` object: ``` { "data": { "key_id": "dzpk_live_3c9e1a7f5b2d80", "store_id": 1234, "type": "platform", "rate_limit_tier": "enterprise", "pilot": true, "scopes": ["orders:read", "whatsapp:read", "whatsapp:send"], "app": { "app_id": 12, "client_id": "dzapp_4e1b9c07d2a86f35e0b1", "install_id": 57 } }, "meta": { "request_id": "8f2c1a9d4b7e6035", "api_version": "v1" } } ``` | Field | Meaning | | ----------------- | -------------------------------------------------------------------------------------- | | `store_id` | The store this token works on. | | `scopes` | The scopes the merchant granted. | | `rate_limit_tier` | Always `enterprise` for install tokens, whatever the store's plan. | | `app.app_id` | Your app's id. | | `app.client_id` | Your app's OAuth client id. | | `app.install_id` | The install on this store. Webhook payloads such as `app.uninstalled` use the same id. | ## Endpoints closed to apps[​](#endpoints-closed-to-apps "Direct link to Endpoints closed to apps") These endpoints answer `403` to an install token, whatever its scopes: | Endpoints | Answer | Why | | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `/v1/keys` and `/v1/keys/{key_id}` | `Apps cannot use this endpoint` | API keys belong to the merchant. | | `/v1/webhooks` and everything under it | `Apps cannot use this endpoint` | Your app's webhook is set once in the developer console for every store (see [Webhooks](https://dzbuild.dev/webhooks.md)). | | `/v1/changes` and everything under it, undo included | `Apps cannot use this endpoint` | The change log and undo stay with the merchant. | `POST /v1/landing-pages/generate` needs the `ai:generate` scope, which apps cannot request because it spends the merchant's AI credits. It answers `403` with `Missing scope: ai:generate`. --- # Build with AI agents Each page is also served as Markdown. The files below give a coding agent the same facts these pages give you. Start with this path: 1. Open [Connect your agent](https://dzbuild.dev/agents/connect), select your client and copy its setup command or configuration. 2. Give the agent one merchant task, such as displaying products and exporting them to CSV. Point it to [Get started](https://dzbuild.dev/getting-started.md), the [API reference](https://dzbuild.dev/api-reference.md) and the `cloudflare-catalog` option in the [app creator](https://dzbuild.dev/apps/new). 3. Follow the starter's setup steps, register the app in the developer console and install it on a store you own to test the result. Connecting an agent gives it documentation access only. Store access requires a merchant-approved app installation and an install token held by your app's server. ## What is here for machines[​](#what-is-here-for-machines "Direct link to What is here for machines") | Resource | URL | Use it for | | -------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Index | `https://dzbuild.dev/llms.txt` | The list of pages with one line each. Paste it into a chat or point an agent at it. | | Whole site | `https://dzbuild.dev/llms-full.txt` | Every page in one Markdown file. | | Any page as Markdown | the page URL plus `.md`, for example `https://dzbuild.dev/oauth.md` | One page without navigation. Arabic and French pages work the same way under `/ar/` and `/fr/`. | | Agent skill | `https://dzbuild.dev/skills/dzbuild-apps/SKILL.md` | The facts and rules an agent needs to build a DZBuild app. | | AGENTS.md template | `https://dzbuild.dev/agents/AGENTS.md` | A starting file for your app's repository. | | OpenAPI description | `https://dzbuild.dev/openapi/dzbuild-apps-v1.json` | Every operation an install token can call, with parameters, scopes and response shapes. | | Example app | [dzbuild-app-starter](https://github.com/DZBuild-com/dzbuild-app-starter) | Three Cloudflare Worker presets and a Node.js example, each with tests. | Every HTML page also carries `` pointing at its Markdown twin, so a tool that reads the page can find the plain version on its own. ## Give your agent the skill[​](#give-your-agent-the-skill "Direct link to Give your agent the skill") The skill file follows the Agent Skills format: a short description that tells the agent when to use it, then the instructions. Claude Code reads skills from `.claude/skills/` in your project or `~/.claude/skills/` on your machine: ``` mkdir -p .claude/skills/dzbuild-apps curl -sSf https://dzbuild.dev/skills/dzbuild-apps/SKILL.md -o .claude/skills/dzbuild-apps/SKILL.md ``` For agents that read an `AGENTS.md` at the root of the repository (Codex, Cursor and others), start from the template and keep your own project rules under it: ``` curl -sSf https://dzbuild.dev/agents/AGENTS.md -o AGENTS.md ``` Both files repeat the rules of this site, so your agent works from the same facts you do. ## Give your agent the docs as an MCP server[​](#give-your-agent-the-docs-as-an-mcp-server "Direct link to Give your agent the docs as an MCP server") `@dzbuild/docs-mcp` gives any MCP client these pages and the API reference, read from dzbuild.dev, with no credentials. The [/agents/connect](https://dzbuild.dev/agents/connect) page has the command or config for Claude Code, Codex, Cursor and Claude Desktop. This MCP server is read-only. It does not install apps, issue tokens, read store data or perform store actions. Use it to check the documentation while building the app that will use the REST API. ## Ask a question about one page[​](#ask-a-question-about-one-page "Direct link to Ask a question about one page") Under every page there is a bar with **View as Markdown**, **Copy Markdown**, **Open in ChatGPT** and **Open in Claude**. The last two open a new chat that starts by reading that page. The prompt they send is: ``` Read https://dzbuild.dev/oauth.md so I can ask questions about it. ``` For a task rather than a question, give the agent the index and the skill together, then describe what the app does for the merchant: ``` Read https://dzbuild.dev/llms.txt and https://dzbuild.dev/skills/dzbuild-apps/SKILL.md. Build a DZBuild app in Node.js that installs on a store through the OAuth flow, stores one token per store, verifies webhooks, and posts a message to our Slack channel when an order is confirmed. Secrets come from environment variables. ``` ## Generate a client from the OpenAPI description[​](#generate-a-client-from-the-openapi-description "Direct link to Generate a client from the OpenAPI description") The description is OpenAPI 3.1. Import it into Postman or Insomnia, or generate types and a client: ``` curl -sO https://dzbuild.dev/openapi/dzbuild-apps-v1.json npx openapi-typescript dzbuild-apps-v1.json -o dzbuild-apps-v1.d.ts ``` The `dzOAuth` security scheme in the file lists every scope with its description. The file declares `https://api.dzbuild.app` as its server, which is the host your app calls. ## Keep secrets out of the chat[​](#keep-secrets-out-of-the-chat "Direct link to Keep secrets out of the chat") An agent only needs to know that a secret exists, never its value. * The client secret, the signing secret and every install token live in environment variables or a secret store on your server. Do not paste them into a prompt, a repository or a log line. * If a secret reaches a chat or a commit, rotate it in the developer console. The old client secret stops at once; tokens already issued keep working, and rotating the signing secret changes it for every install at once, so deploy the new value right away. * An agent must not sign in to the DZBuild dashboard as the merchant, script it, or scrape storefront pages. Store data is reached only through the REST API and webhooks, as the [security rules](https://dzbuild.dev/security.md) say. * Redirect URIs are compared character for character. Register in the console the exact URI your code sends, on `https`, with the same trailing slash. ## What assistants get wrong without the docs[​](#what-assistants-get-wrong-without-the-docs "Direct link to What assistants get wrong without the docs") These come up often when an app is written from general OAuth or webhook knowledge. The pages linked settle each one. * Expecting `expires_in` and a refresh token. Install tokens have neither; they work until the merchant uninstalls or installs again. See [Core concepts](https://dzbuild.dev/concepts.md). * Sending a write without an `Idempotency-Key`. `POST`, `PATCH` and `DELETE` answer `400` without one. See [Rate limits](https://dzbuild.dev/rate-limits.md). * Parsing the webhook body before checking the signature. The HMAC covers the raw bytes, so a re-serialized body never matches. See [Webhooks](https://dzbuild.dev/webhooks.md). * Registering the webhook through `POST /v1/webhooks`. Apps get `403` there; the webhook URL is set once on the app in the console. See [API reference](https://dzbuild.dev/api-reference.md). * Asking for every scope. The merchant sees one line per resource on the consent screen, and the review compares the scopes with what the listing says the app does. See [Scopes](https://dzbuild.dev/scopes.md). * Building a store switcher inside one token. A token reaches its own store only; use the token of each store from the `stores` list of the token response. See [OAuth](https://dzbuild.dev/oauth.md). --- # Changelog This page lists dated changes to the DZBuild developer platform. Changes to the merchant REST API that do not concern apps are listed in the [API changelog on dzbuild.com](https://dzbuild.com/api-docs/changelog). ## 2026-10-08: store design fields and theme lists[​](#2026-10-08-store-design-fields-and-theme-lists "Direct link to 2026-10-08: store design fields and theme lists") Apps with `store:read` and `store:write` reach more of the store's design: * **Buy bar fields** in `GET /v1/store/design` and `PATCH /v1/store/design`: the switches `buybar_show_mobile`, `buybar_show_qty` and `fc_show_qty`, and `product_button_size` (`normal` or `large`), open on every plan. * **`GET /v1/store`** returns `store_theme`, `fast_checkout_theme` and `variant_card_style`, read-only. * **`GET /v1/themes`** adds the `fast_checkout` and `variant_styles` lists, each item with its plan, `can_use` and `current`. * **`change_id`** in the answers of `PATCH /v1/store/design` and the three theme switches. Install tokens still get `403` on `/v1/changes`. ## 2026-09-26: platform launch[​](#2026-09-26-platform-launch "Direct link to 2026-09-26: platform launch") Third-party apps are open to developers. This release contains: * **Developer console** at `https://dzbuild.com/dashboard/developer`. Register an app, get a `client_id`, a client secret and a signing secret, rotate both secrets, test on your own stores and submit the app for review. * **Install flow**: OAuth 2.0 authorization code with PKCE `S256` only. `GET /oauth/apps/authorize` shows the consent screen, `POST /oauth/apps/token` returns the install tokens. One consent covers up to 10 stores owned by the merchant. See [OAuth](https://dzbuild.dev/oauth.md). * **Install tokens**: one `dzpk_live_` bearer token per store, limited to the approved scopes, with no expiry. Uninstalling the app revokes it. Any store plan can install an app; set `min_plan` to require a higher one. * **19 scopes** for apps, covering the store, products, orders, delivery, customers, landing pages, promo codes, pixels, shipping, analytics and WhatsApp. `ai:generate` is not available to apps. See [Scopes](https://dzbuild.dev/scopes.md). * **App object in `GET /v1/whoami`**: `app_id`, `client_id` and `install_id` for calls made with an install token. * **Per-install rate limit** of 120 requests per minute, checked before the store's shared limit. See [Rate limits](https://dzbuild.dev/rate-limits.md). * **App errors**: `403` with `app_uninstalled`, `app_suspended`, `app_not_approved` or `app_plan_required`. Install tokens get `403` on `/v1/keys`, `/v1/webhooks` and `/v1/changes`. * **Webhooks for apps**: order events and `app.uninstalled`, signed with `X-DZ-Signature`. Order events need the `orders:read` scope. See [Webhooks](https://dzbuild.dev/webhooks.md). * **Launch link**: the merchant's Open button redirects to your launch URL with a signed HS256 token in `dz_launch`, valid for 5 minutes. * **WhatsApp API**: `GET /v1/whatsapp/templates`, `GET /v1/whatsapp/balance`, `GET /v1/whatsapp/messages` and `POST /v1/orders/{id}/whatsapp`, with the scopes `whatsapp:read` and `whatsapp:send`. See [WhatsApp API](https://dzbuild.dev/whatsapp.md). * **OpenAPI description** of the public `/v1` API, downloadable from the [API reference](https://dzbuild.dev/api-reference.md). --- # Core concepts ## Apps[​](#apps "Direct link to Apps") An app is the record you create in the developer console. It holds your credentials, your redirect URIs, the scopes you may request, your webhook settings and a minimum plan. Each app has one status: | Status | Who can install it | What happens to existing installs | | ----------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `draft` | Only the developer, on stores the developer account owns | Tokens work on the developer's own stores | | `in_review` | Only the developer, on stores the developer account owns | Tokens work on the developer's own stores. An approved app sent back to review after an edit keeps every existing install working. | | `approved` | Any store owner | Tokens work | | `rejected` | Nobody | Every call answers `403` with `app_suspended` | | `suspended` | Nobody | Every call answers `403` with `app_suspended` | DZBuild reviews an app after you submit it from the console. The [review guidelines](https://dzbuild.dev/review-guidelines.md) list what the review checks. ## Test mode[​](#test-mode "Direct link to Test mode") A draft or in-review app runs in test mode. The consent screen accepts it only for the developer who owns it, and only for stores that account owns. This lets you build and test the whole flow before review, with real data from your own store. The API applies the same rule on every call. If an app has not been approved yet and the install belongs to someone other than the developer, the call answers `403` with `app_not_approved` and the message `This app is in test mode and only runs on its developer's stores`. ## Stores[​](#stores "Direct link to Stores") A store is one DZBuild shop, with its own products, orders and plan. One merchant account can own several stores. * Only the store owner can install an app. The consent screen lists only the stores the logged-in account owns, so a team member cannot pick a store they work on. * One consent can cover up to 10 stores. Each store gets its own install and its own token. * A token reads and writes only the store it was issued for. The token response lists every installed store in `stores`, and repeats the first one at the top level. ## Installs[​](#installs "Direct link to Installs") An install links one app to one store. There is at most one install per app and store. The merchant starts an install from the Extensions page of their dashboard. The Install button opens your homepage URL, or your launch URL when no homepage is set, in a new tab. DZBuild does not send the merchant to the authorize URL for you, so that page must start the OAuth flow. An app approved as unlisted is not on the Extensions page until the store installs it, so the merchant opens that page from a link you share. See [listed or unlisted](https://dzbuild.dev/review-guidelines.md#listed-or-unlisted). * The install is created when your server exchanges the code at the token endpoint, not when the merchant clicks approve. * If the merchant installs your app again on the same store, the install keeps its `install_id`. DZBuild issues a new token, revokes the old one and stores the scopes from the latest consent. * Only the merchant can uninstall, from your app's page in their dashboard. There is no revoke endpoint for apps. When a merchant uninstalls your app, DZBuild does four things at once: it marks the install as uninstalled, revokes the token, drops the webhook deliveries still waiting for your endpoint, and queues one `app.uninstalled` event if your endpoint is verified and active. From then on the old token answers `401`. ## Plans and min_plan[​](#plans-and-min_plan "Direct link to Plans and min_plan") DZBuild stores are on one of four plans, from lowest to highest: `free`, `pro`, `unlimited`, `enterprise`. Any plan can install an app. The Enterprise plan requirement for merchant API keys does not apply to install tokens. You can raise the bar with `min_plan` in the console. It defaults to `free`, which allows every store. * On the consent screen, a store below `min_plan` is shown as not eligible and cannot be picked. * On every API call, a store below `min_plan` answers `403` with `app_plan_required` and a message that names the plan, for example `This app requires the Pro plan`. * A paid plan that has expired counts as `free` in both checks. ## Per-install rate limit[​](#per-install-rate-limit "Direct link to Per-install rate limit") Each install has its own budget of 120 requests per minute. The window is a fixed calendar minute, so the counter resets at the start of each minute. The install budget is checked first. After it, the request also counts against the store's shared per-minute budget, which the merchant's own keys and other apps use too. A runaway app hits its own limit before it can use up the store's. Over the limit, the API answers `429` with a `Retry-After` header and this body: ``` { "error": { "code": "rate_limited", "message": "Per-minute API limit exceeded for this app install", "retry_after": 42 }, "meta": { "request_id": "5f2c9a0b1d3e4f60", "api_version": "v1" } } ``` If the store budget runs out first, the message is `Per-minute API limit exceeded for this store`. [Rate limits](https://dzbuild.dev/rate-limits.md) explains how to back off. ## Install tokens[​](#install-tokens "Direct link to Install tokens") An install token is the bearer token your server receives from the token endpoint. It is an API key of the same kind a merchant creates, marked as belonging to your install. What an install token is: * A bearer token that starts with `dzpk_live_`, sent as `Authorization: Bearer ...`. * One token per install, so one per store. * Limited to the scopes the merchant approved, checked by the same scope rules as every other key. * Reported as `rate_limit_tier: enterprise` by `GET /v1/whoami` on any plan, with the per-install limit above as the real budget. * Valid with no expiry date. The token endpoint returns no `expires_in` and no refresh token. * Revoked when the merchant uninstalls your app, and replaced when the merchant installs it again. What an install token is not: * It is not a merchant key. It does not appear on the merchant's API keys page and does not count toward the merchant's key limit. * It cannot manage keys, webhooks or the change log. Calls to `/v1/keys`, `/v1/webhooks` and `/v1/changes` answer `403` with the code `forbidden` and the message `Apps cannot use this endpoint`. * It is not tied to a person's session. It keeps working when the merchant logs out. * It does not cover several stores. Use the token issued for each store. Every refusal that is specific to apps answers `403` with the error envelope: | Code | Message | Cause | | ------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | | `app_uninstalled` | `This app is no longer installed on this store` | The install is no longer active | | `app_suspended` | `This app has been suspended by DZBuild` | The app is suspended or rejected | | `app_not_approved` | `This app is in test mode and only runs on its developer's stores` | The app has not been approved yet and the store owner is not the developer | | `app_plan_required` | `This app requires the Pro plan` (the plan name varies) | The store is below `min_plan` | --- # Errors This page gathers the errors described across the other pages so you can handle them in one place. The API answers every error with the same JSON envelope; the OAuth endpoints use the RFC 6749 shape instead. ## The API error envelope[​](#the-api-error-envelope "Direct link to The API error envelope") ``` { "error": { "code": "forbidden", "message": "Missing scope: orders:write" }, "meta": { "request_id": "8f2c1a9d4b7e6035", "api_version": "v1" } } ``` `error.code` is stable and meant for your code. `error.message` is for humans and can change. Quote `meta.request_id` when you write to DZBuild about a call. Some errors add fields inside `error`, for example `retry_after` on a `429`. ## API status codes[​](#api-status-codes "Direct link to API status codes") | Status | `error.code` | Message | Cause and what to do | | ------ | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `bad_request` | varies | The `Idempotency-Key` header is missing or malformed on a `POST`, `PATCH` or `DELETE`, the body is not valid JSON, or a path id is not numeric. Fix the request; send a new key. | | `401` | `unauthorized` | `Missing Authorization header` | No `Authorization` header. | | `401` | `unauthorized` | `Unsupported Authorization scheme` | The header does not start with `Bearer`. | | `401` | `unauthorized` | `Invalid or revoked API key` | The token is wrong, or the merchant uninstalled your app. Treat it as an uninstall unless you know otherwise. | | `402` | `no_credit` | | The store's WhatsApp wallet is empty. Nothing was queued or charged. Retrying does not help; the merchant tops up in the dashboard. | | `402` | `quota_exceeded` | | The store reached a monthly request quota DZBuild set for it. Retrying does not help. | | `403` | `forbidden` | `Missing scope: ` | The token lacks the scope the endpoint needs. Some writes need the read scope too; see [Scopes](https://dzbuild.dev/scopes.md). | | `403` | `forbidden` | `Apps cannot use this endpoint` | `/v1/keys`, `/v1/webhooks` and `/v1/changes` are closed to install tokens. | | `403` | `app_uninstalled` | `This app is no longer installed on this store` | The install is no longer active. Stop using the token and delete the store's data. | | `403` | `app_suspended` | `This app has been suspended by DZBuild` | DZBuild suspended or rejected the app. Calls work again once the suspension is lifted. | | `403` | `app_not_approved` | `This app is in test mode and only runs on its developer's stores` | The app has not been approved yet and the store does not belong to you. | | `403` | `app_plan_required` | `This app requires the ... plan` | The store's plan, or an expired paid plan, is below the app's minimum plan. Tell the merchant which plan is needed. | | `403` | `addon_not_active` | | WhatsApp only. The merchant has not turned on the WhatsApp Sender addon. | | `403` | `plan_required` | | Home page sections only. The write would leave more sections than the store's plan allows. `error` carries `plan` and `cap`. | | `404` | `not_found` | | No record with this id on this store. | | `404` | `section_not_found` | | Home page sections only. No section with this id on the store's home page. | | `409` | `already_sent` | | WhatsApp only. This template was already sent for this order. `error` carries the existing message `id` and `status`. | | `409` | `write_conflict` | | Home page sections only. Another write changed the layout first, or the `version` sent on `PUT` is not the current one. When `error` carries `sections` and `version`, retry from them; otherwise read the layout again. | | `422` | `idempotency_key_reuse` | | The same `Idempotency-Key` was sent with a different method, path or body. Use one key per operation. | | `422` | WhatsApp codes | | `unknown_template`, `invalid_language`, `invalid_number`, `suppressed`, `template_not_approved`, `empty_param`. Nothing was charged. Fix the cause and call again with a new key. See [WhatsApp API](https://dzbuild.dev/whatsapp.md). | | `422` | home page section codes | | `invalid_settings` (`error.fields` lists the refused settings, on `PUT` those of the first refused section), `invalid_section_type`, `limit_reached`, `invalid_order`, `no_changes`. Fix the request and call again with a new key. See [Home page sections](https://dzbuild.dev/home-layout.md). | | `429` | `rate_limited` | `Per-minute API limit exceeded for this app install` | Your install's budget of 120 requests per minute is used up. Wait `retry_after` seconds (also in the `Retry-After` header) and resend with the same `Idempotency-Key`. | | `429` | `rate_limited` | `Per-minute API limit exceeded for this store` | The store's shared budget is used up. Same handling. | | `429` | `rate_limited` | `Per-minute API limit exceeded for this key` | The gateway's ceiling of 600 requests per minute per store. Same handling. | | `429` | `too_many_concurrent` | | Too many costly operations at once (image uploads, courier calls, home page section writes). `retry_after` is 5 seconds. | | `500` | `send_failed` | | WhatsApp only. The message could not be queued. Retry later. | | `502` | `server_error` | `Key lookup failed, retry shortly` | The gateway could not check the token with DZBuild. Retry after a short wait with the same `Idempotency-Key`. | ## Retry or not[​](#retry-or-not "Direct link to Retry or not") | Answer | Retry | With | | --------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | `429` | Yes, after `retry_after` seconds | The same `Idempotency-Key` | | `5xx` and `502` | Yes, after a short wait | The same `Idempotency-Key`; these answers are never stored, so the request runs | | `400`, `401`, `403`, `404`, `422` | No. Fix the cause first | A new `Idempotency-Key`, because a stored `4xx` is replayed for 24 hours | | `402` | No. The merchant has to act | | | `409 already_sent` | No. The message exists | | | `409 write_conflict` | Yes, from the `sections` and `version` in `error`, or after reading the layout again | A new `Idempotency-Key` | ## OAuth errors[​](#oauth-errors "Direct link to OAuth errors") Before DZBuild has matched your `client_id` and `redirect_uri`, it shows an error page to the merchant and never redirects. After that, it redirects to your `redirect_uri` with an `error` parameter: | `error` | Cause | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `unsupported_response_type` | `response_type` is not `code`. | | `invalid_request` | `state` missing or longer than 1024 characters, `code_challenge` not 43 base64url characters, or `code_challenge_method` not `S256`. | | `invalid_scope` | A scope not registered on the app, not allowed for apps, or a `scope` parameter longer than 512 characters. | | `access_denied` | The merchant clicked Deny. | The token endpoint answers JSON with `error` and `error_description`: | HTTP | `error` | Cause | | ----- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | `invalid_client` | Missing or wrong `client_id` or `client_secret`. | | `400` | `unsupported_grant_type` | `grant_type` is not `authorization_code`. | | `400` | `invalid_grant` | Unknown, expired or used code, a code issued to another app, a different `redirect_uri`, a verifier that does not match, or no selected store could be installed. | | `500` | `server_error` | A platform fault. Send the merchant through the authorize step again. | | `403` | none, HTML body | The request carried no `User-Agent` header: `dzbuild.com` answers a `POST` without one with a challenge page instead of JSON. Send one, for example `my-app/1.0 (+https://example.com)`. | A claimed code is spent even when the exchange fails, so start a new authorize request instead of retrying with the same code. The full order of checks and the error pages are on the [OAuth](https://dzbuild.dev/oauth.md) page. ## Webhook delivery failures[​](#webhook-delivery-failures "Direct link to Webhook delivery failures") A delivery succeeds when your server answers `2xx` within 10 seconds. Any other status, a redirect, a timeout or a connection error counts as a failed attempt. DZBuild tries each delivery up to 5 times with growing waits, then marks it dead. After 10 failed attempts in a row on one install, that endpoint is disabled; press Verify in the developer console to turn it back on. Details and the schedule are on the [Webhooks](https://dzbuild.dev/webhooks.md) page. Your own verification should answer `401` when the signature or the timestamp check fails. Because that is a non-`2xx` answer, DZBuild counts it as a failed attempt and retries; a request that keeps failing verification is not from DZBuild, or your signing secret is out of date. --- # Frequently asked questions ## What is a DZBuild app?[​](#what-is-a-dzbuild-app "Direct link to What is a DZBuild app?") A DZBuild app is a service you run on your own servers. A store owner installs it from their DZBuild dashboard through an OAuth 2.0 consent screen, and your server then reads and changes that store's data through the REST API at `https://api.dzbuild.app/v1`. None of your code runs on DZBuild. [Core concepts](https://dzbuild.dev/concepts.md) describes apps, installs, stores and tokens. ## Who can build a DZBuild app?[​](#who-can-build-a-dzbuild-app "Direct link to Who can build a DZBuild app?") Any DZBuild account that owns a store can open the [developer console](https://dzbuild.com/dashboard/developer), register up to 10 apps and test them on its own stores. Other merchants can install an app after DZBuild reviews and approves it. [Get started](https://dzbuild.dev/getting-started.md) walks through the first app. ## Which store plans can install an app?[​](#which-store-plans-can-install-an-app "Direct link to Which store plans can install an app?") Every plan, from Free to Enterprise. The Enterprise plan requirement that applies to merchant API keys does not apply to app install tokens. A developer can set a minimum plan on the app; stores below it cannot install it and get a `403` `app_plan_required` answer. See [plans and min_plan](https://dzbuild.dev/concepts.md#plans-and-min_plan). ## Do install tokens expire?[​](#do-install-tokens-expire "Direct link to Do install tokens expire?") No. The token endpoint returns no `expires_in` and no refresh token. A token works until the merchant uninstalls the app, which revokes it, or installs the app again on the same store, which replaces it. See [token lifetime and revocation](https://dzbuild.dev/oauth.md#token-lifetime-and-revocation). ## How do I test an app before it is approved?[​](#how-do-i-test-an-app-before-it-is-approved "Direct link to How do I test an app before it is approved?") A new app is a draft and runs in test mode: it installs only on stores your own account owns, with real data. Register an `https` redirect URI, deploy the app to a `workers.dev` address (or run an https tunnel), install the app on your store from the authorize URL and call `GET /v1/whoami` with the token. The steps are on the [Get started](https://dzbuild.dev/getting-started.md) page. ## Can my app send WhatsApp messages to buyers?[​](#can-my-app-send-whatsapp-messages-to-buyers "Direct link to Can my app send WhatsApp messages to buyers?") Yes, with the `whatsapp:send` scope. Your app sends the order templates that Meta approved for DZBuild, in Arabic or French, and each message costs one credit from the store's WhatsApp wallet. Free text and custom templates are not available. See the [WhatsApp API](https://dzbuild.dev/whatsapp.md). ## Which webhook events does DZBuild send to apps?[​](#which-webhook-events-does-dzbuild-send-to-apps "Direct link to Which webhook events does DZBuild send to apps?") `order.created`, `order.confirmed`, `order.processing`, `order.shipped`, `order.delivered`, `order.cancelled` and `order.returned`, plus `app.uninstalled` and the `webhook.verify` request from the console. Order events need the `orders:read` scope because the payload carries the buyer's name, phone and address. Every request is signed with `X-DZ-Signature`. See [Webhooks](https://dzbuild.dev/webhooks.md). ## Can a Worker on workers.dev receive webhooks?[​](#can-a-worker-on-workersdev-receive-webhooks "Direct link to Can a Worker on workers.dev receive webhooks?") No. DZBuild refuses webhook URLs on `workers.dev`. Put the Worker on a domain you own on your Cloudflare account, or poll `GET /v1/orders?since=` once a minute for new orders, which is what the Order notifications preset does. ## What does the DZBuild app review check?[​](#what-does-the-dzbuild-app-review-check "Direct link to What does the DZBuild app review check?") The listing (name, logo, descriptions in English, Arabic and French, homepage, support email), the redirect URIs, the requested scopes against what the listing says, the webhook URL and its verification, the developer account, the test installs and the [security rules](https://dzbuild.dev/security.md): exact redirect URIs, secrets kept server side, verified signatures, data deletion after uninstall, no scraping and answered support email. The [review guidelines](https://dzbuild.dev/review-guidelines.md) list what the console requires before you can submit. ## Can an app manage API keys or webhooks through the API?[​](#can-an-app-manage-api-keys-or-webhooks-through-the-api "Direct link to Can an app manage API keys or webhooks through the API?") No. `/v1/keys`, `/v1/webhooks` and `/v1/changes` answer `403` with the message `Apps cannot use this endpoint` to any install token. The app's webhook URL is set once in the developer console and receives the events of every store that installs the app. See [endpoints closed to apps](https://dzbuild.dev/api-reference.md#endpoints-closed-to-apps). ## Can I use these docs with an AI coding assistant?[​](#can-i-use-these-docs-with-an-ai-coding-assistant "Direct link to Can I use these docs with an AI coding assistant?") Yes. Every page has a Markdown twin at the same URL plus `.md`, the index is `https://dzbuild.dev/llms.txt`, the whole site is `https://dzbuild.dev/llms-full.txt`, and an agent skill at `https://dzbuild.dev/skills/dzbuild-apps/SKILL.md` carries the rules an assistant needs. The OpenAPI description for apps is at `https://dzbuild.dev/openapi/dzbuild-apps-v1.json`. [Build with AI agents](https://dzbuild.dev/build-with-ai.md) explains how to use them. --- # Get started This page takes you from an empty developer console to a first authenticated API call. You register an app, install it on a store you own, exchange the code for an install token and call `GET /v1/whoami`. There are three separate steps: **deploy** your app to give it a URL, **register** it in the developer console to get its credentials, then **install** it on a store to receive that store's token. The app creator prepares your starter and settings; you still complete registration and installation in DZBuild. ## Before you start[​](#before-you-start "Direct link to Before you start") * A DZBuild account that owns at least one store. A test install only works on a store your account owns, not on a store where you are a team member. * A redirect URI on `https` that your app controls. The console refuses `http`. The quickest option is a Worker on `workers.dev` from [/apps/new](https://dzbuild.dev/apps/new), registered once; an https tunnel to your machine also works, but its address changes on each run and the console holds at most 5 redirect URIs. * `curl` and `openssl` on your machine. ## Start from the example app[​](#start-from-the-example-app "Direct link to Start from the example app") The fastest start is [/apps/new](https://dzbuild.dev/apps/new): pick one of three Cloudflare presets, deploy it to a free `workers.dev` address and get the exact values for the console. * `cloudflare-basic`: the install flow, one token per store and the launch link. The base for your own app. * `cloudflare-catalog`: a products page for the merchant and a CSV export of the catalog. * `cloudflare-orders`: new-order alerts to Telegram by polling `GET /v1/orders` once a minute; order webhooks once you own a domain. **Example: export a product catalog.** Choose `cloudflare-catalog` if your first task is to display a merchant's products and let them download a CSV: 1. Deploy the preset and complete its README setup steps. 2. Register the app with the URLs supplied by the starter. Product reads need `products:read`; use the starter's documented scopes for the full app. 3. Install it on a store you own, open the products page and download the CSV. The export uses that store's real product data, so this also verifies which store your install token reaches. The manual walkthrough below uses the basic app to explain the shared install flow. For help preparing the app with an agent, see [Build with AI](https://dzbuild.dev/build-with-ai.md). Scaffold the basic preset in one command, or deploy it from the browser with the button below. Cloudflare clones it into your GitHub account and deploys it; the preset README lists the steps that follow. ``` npm create cloudflare@latest my-app -- --template DZBuild-com/dzbuild-app-starter/cloudflare-basic --no-agents --no-git --no-deploy --no-open ``` [![Deploy to Cloudflare](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjM5IiB2aWV3Qm94PSIwIDAgMTg0IDM5IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoCiAgICBkPSJNMSA0QzEgMi4zNDMxNSAyLjM0MzE1IDEgNCAxSDE4MEMxODEuNjU3IDEgMTgzIDIuMzQzMTUgMTgzIDRWMzVDMTgzIDM2LjY1NjkgMTgxLjY1NyAzOCAxODAgMzhINEMyLjM0MzE0IDM4IDEgMzYuNjU2OSAxIDM1VjRaIgogICAgZmlsbD0iYmxhY2siIC8+CiAgPHBhdGgKICAgIGQ9Ik0xIDRDMSAyLjM0MzE1IDIuMzQzMTUgMSA0IDFIMTgwQzE4MS42NTcgMSAxODMgMi4zNDMxNSAxODMgNFYzNUMxODMgMzYuNjU2OSAxODEuNjU3IDM4IDE4MCAzOEg0QzIuMzQzMTQgMzggMSAzNi42NTY5IDEgMzVWNFoiCiAgICBzdHJva2U9IiNGNjgyMUYiIHN0cm9rZS13aWR0aD0iMiIgLz4KICA8cGF0aAogICAgZD0iTTI4Ljg4NjggMjQuNzc0NUwyOS4wMDk1IDI0LjM1QzI5LjE1NTggMjMuODQ0OSAyOS4xMDE0IDIzLjM3OCAyOC44NTU5IDIzLjAzNDlDMjguNjMwMiAyMi43MTg5IDI4LjI1MzkgMjIuNTMyOCAyNy43OTcgMjIuNTExMkwxOS4xNDE2IDIyLjQwMDlDMTkuMDgzMyAyMi4zOTc4IDE5LjAzNTEgMjIuMzcxNSAxOS4wMDU0IDIyLjMyODNDMTguOTc1MyAyMi4yODM5IDE4Ljk2ODMgMjIuMjI2OCAxOC45ODY5IDIyLjE3MkMxOS4wMTU0IDIyLjA4NjcgMTkuMDk5OSAyMi4wMjIzIDE5LjE4NzkgMjIuMDE4NEwyNy45MjM2IDIxLjkwNzdDMjguOTU5NyAyMS44NjAyIDMwLjA4MTYgMjEuMDE5NCAzMC40NzQ1IDE5Ljk5NEwzMC45NzI3IDE4LjY5MjRDMzAuOTg2NCAxOC42NTcgMzAuOTkyNiAxOC42MTk0IDMwLjk5MjMgMTguNTgxN0MzMC45OTIyIDE4LjU2MTIgMzAuOTkwNyAxOC41NDA2IDMwLjk4NjIgMTguNTIwM0MzMC40MjAxIDE1Ljk3NjEgMjguMTQ5NyAxNC4wNzM2IDI1LjQzNDggMTQuMDczNkMyMi45MzMzIDE0LjA3MzYgMjAuODA5MiAxNS42ODgyIDIwLjA0NzQgMTcuOTMyMkMxOS41NTU3IDE3LjU2MzMgMTguOTI2NyAxNy4zNjcyIDE4LjI1MDYgMTcuNDM0OEMxNy4wNTA0IDE3LjU1NCAxNi4wODYgMTguNTIwMyAxNS45NjY3IDE5LjcyMDRDMTUuOTM1NCAyMC4wMzE5IDE1Ljk2MDkgMjAuMzMyNSAxNi4wMzI3IDIwLjYxNUMxNC4wNzIyIDIwLjY3MjEgMTIuNSAyMi4yNzgyIDEyLjUgMjQuMjUyNEMxMi41IDI0LjQzMTEgMTIuNTEzNSAyNC42MDY3IDEyLjUzODYgMjQuNzc4NEMxMi41NTA5IDI0Ljg2MTggMTIuNjIxMiAyNC45MjQzIDEyLjcwNTMgMjQuOTI0M0wyOC42ODQ2IDI0LjkyNjJDMjguNjg2MiAyNC45MjYyIDI4LjY4NzYgMjQuOTI1NSAyOC42ODkyIDI0LjkyNTRDMjguNzgwNiAyNC45MjM0IDI4Ljg2MSAyNC44NjI5IDI4Ljg4NjggMjQuNzc0NVoiCiAgICBmaWxsPSJ1cmwoI3BhaW50MF9saW5lYXJfMjYxXzU4NTkpIiAvPgogIDxwYXRoCiAgICBkPSJNMzEuNzY5NSAxOC43ODc5QzMxLjY4OTIgMTguNzg3OSAzMS42MDkzIDE4Ljc5MDIgMzEuNTI5OCAxOC43OTQxQzMxLjUxNjcgMTguNzk0OCAzMS41MDQyIDE4Ljc5OCAzMS40OTIzIDE4LjgwMjJDMzEuNDUwOCAxOC44MTY3IDMxLjQxNzcgMTguODUwMyAzMS40MDUxIDE4Ljg5NDFMMzEuMDY0OCAyMC4wNjk1QzMwLjkxODUgMjAuNTc0NiAzMC45NzI5IDIxLjA0MTIgMzEuMjE4NCAyMS4zODQyQzMxLjQ0NDEgMjEuNzAwNyAzMS44MjA0IDIxLjg4NjMgMzIuMjc3MyAyMS45MDc5TDM0LjEyMjQgMjIuMDE4N0MzNC4xNzY4IDIyLjAyMTMgMzQuMjI0NyAyMi4wNDc2IDM0LjI1NCAyMi4wOUMzNC4yODQ4IDIyLjEzNDggMzQuMjkxOCAyMi4xOTIzIDM0LjI3MzMgMjIuMjQ3MUMzNC4yNDQzIDIyLjMzMjQgMzQuMTYwMiAyMi4zOTY4IDM0LjA3MjYgMjIuNDAwN0wzMi4xNTUzIDIyLjUxMTRDMzEuMTE0NSAyMi41NTkzIDI5Ljk5MjcgMjMuMzk5OCAyOS41OTk4IDI0LjQyNTFMMjkuNDYxMiAyNC43ODcxQzI5LjQzNiAyNC44NTI2IDI5LjQ4MyAyNC45MjIzIDI5LjU1MjIgMjQuOTI1OEMyOS41NTQgMjQuOTI1OCAyOS41NTU2IDI0LjkyNjQgMjkuNTU3MyAyNC45MjY0SDM2LjE1NDJDMzYuMjMzIDI0LjkyNjQgMzYuMzAzMiAyNC44NzUxIDM2LjMyNDQgMjQuNzk5NEMzNi40MzkgMjQuMzkxOSAzNi41IDIzLjk2MjQgMzYuNSAyMy41MTgzQzM2LjUgMjAuOTA1NyAzNC4zODIxIDE4Ljc4NzkgMzEuNzY5NSAxOC43ODc5WiIKICAgIGZpbGw9InVybCgjcGFpbnQxX2xpbmVhcl8yNjFfNTg1OSkiIC8+CiAgPHBhdGgKICAgIGQ9Ik00Ni4wODAxIDI0LjVWMTQuNjM1N0g0OS42ODI2QzUyLjYyMjEgMTQuNjM1NyA1NC4zNzIxIDE2LjQxMzEgNTQuMzcyMSAxOS41MzAzVjE5LjU0MzlDNTQuMzcyMSAyMi42NjggNTIuNjM1NyAyNC41IDQ5LjY4MjYgMjQuNUg0Ni4wODAxWk00Ny44NDM4IDIzLjAwOThINDkuNDk4QzUxLjQ4MDUgMjMuMDA5OCA1Mi41NzQyIDIxLjc5OTggNTIuNTc0MiAxOS41NTA4VjE5LjUzNzFDNTIuNTc0MiAxNy4zMzU5IDUxLjQ2IDE2LjEyNiA0OS40OTggMTYuMTI2SDQ3Ljg0MzhWMjMuMDA5OFpNNTguNzg4MSAyNC42NDM2QzU2LjYxNDMgMjQuNjQzNiA1NS4zMDE4IDIzLjIwMTIgNTUuMzAxOCAyMC44OTc1VjIwLjg5MDZDNTUuMzAxOCAxOC42MDc0IDU2LjYzNDggMTcuMTAzNSA1OC43MDYxIDE3LjEwMzVDNjAuNzc3MyAxNy4xMDM1IDYyLjA2MjUgMTguNTU5NiA2Mi4wNjI1IDIwLjc0NzFWMjEuMjkzOUg1Ni45OTAyQzU3LjAxNzYgMjIuNTY1NCA1Ny43MTQ4IDIzLjMxNzQgNTguODIyMyAyMy4zMTc0QzU5LjY2OTkgMjMuMzE3NCA2MC4yMjM2IDIyLjg2NjIgNjAuMzg3NyAyMi4zODA5TDYwLjQwMTQgMjIuMzMzSDYxLjk5NDFMNjEuOTczNiAyMi40MDgyQzYxLjc0OCAyMy41MjI1IDYwLjY4MTYgMjQuNjQzNiA1OC43ODgxIDI0LjY0MzZaTTU4LjcyNjYgMTguNDM2NUM1Ny44MjQyIDE4LjQzNjUgNTcuMTQ3NSAxOS4wNDQ5IDU3LjAxMDcgMjAuMTU5Mkg2MC40MDgyQzYwLjI4NTIgMTkuMDEwNyA1OS42Mjg5IDE4LjQzNjUgNTguNzI2NiAxOC40MzY1Wk02My4xNjMxIDI2Ljg5MjZWMTcuMjUzOUg2NC44NjUyVjE4LjUwNDlINjQuODk5NEM2NS4zMTY0IDE3LjYzNjcgNjYuMTA5NCAxNy4xMDM1IDY3LjE0ODQgMTcuMTAzNUM2OC45ODczIDE3LjEwMzUgNzAuMTQ5NCAxOC41MzkxIDcwLjE0OTQgMjAuODc3VjIwLjg4MzhDNzAuMTQ5NCAyMy4yMDggNjguOTg3MyAyNC42NDM2IDY3LjE2ODkgMjQuNjQzNkM2Ni4xMzY3IDI0LjY0MzYgNjUuMzA5NiAyNC4xMjQgNjQuODk5NCAyMy4yNzY0SDY0Ljg2NTJWMjYuODkyNkg2My4xNjMxWk02Ni42NDI2IDIzLjIwOEM2Ny43Mjk1IDIzLjIwOCA2OC40MTMxIDIyLjMxMjUgNjguNDEzMSAyMC44ODM4VjIwLjg3N0M2OC40MTMxIDE5LjQyNzcgNjcuNzI5NSAxOC41MzkxIDY2LjY0MjYgMTguNTM5MUM2NS41ODk4IDE4LjUzOTEgNjQuODUxNiAxOS40NTUxIDY0Ljg1MTYgMjAuODc3VjIwLjg4MzhDNjQuODUxNiAyMi4yOTg4IDY1LjU4OTggMjMuMjA4IDY2LjY0MjYgMjMuMjA4Wk03MS4yOTEgMjQuNVYxNC42MzU3SDcyLjk5MzJWMjQuNUg3MS4yOTFaTTc3LjY0ODQgMjQuNjQzNkM3NS41MTU2IDI0LjY0MzYgNzQuMTQxNiAyMy4yMjE3IDc0LjE0MTYgMjAuODc3VjIwLjg2MzNDNzQuMTQxNiAxOC41MzkxIDc1LjUzNjEgMTcuMTAzNSA3Ny42NDE2IDE3LjEwMzVDNzkuNzUzOSAxNy4xMDM1IDgxLjE1NTMgMTguNTI1NCA4MS4xNTUzIDIwLjg2MzNWMjAuODc3QzgxLjE1NTMgMjMuMjI4NSA3OS43NzQ0IDI0LjY0MzYgNzcuNjQ4NCAyNC42NDM2Wk03Ny42NTUzIDIzLjI2OTVDNzguNzI4NSAyMy4yNjk1IDc5LjQxODkgMjIuMzk0NSA3OS40MTg5IDIwLjg3N1YyMC44NjMzQzc5LjQxODkgMTkuMzUyNSA3OC43MjE3IDE4LjQ4NDQgNzcuNjQxNiAxOC40ODQ0Qzc2LjU4MiAxOC40ODQ0IDc1Ljg3NzkgMTkuMzU5NCA3NS44Nzc5IDIwLjg2MzNWMjAuODc3Qzc1Ljg3NzkgMjIuNDAxNCA3Ni41Njg0IDIzLjI2OTUgNzcuNjU1MyAyMy4yNjk1Wk04Mi45NiAyNi45MDYyQzgyLjYzMTggMjYuOTA2MiA4Mi4yOSAyNi44NjUyIDgyLjA4NSAyNi44MzExVjI1LjUzMjJDODIuMjE0OCAyNS41NTk2IDgyLjQxMzEgMjUuNTkzOCA4Mi42NTIzIDI1LjU5MzhDODMuMzAxOCAyNS41OTM4IDgzLjY2NDEgMjUuNDA5MiA4My44ODI4IDI0LjgwMDhMODMuOTg1NCAyNC41MDY4TDgxLjM4MDkgMTcuMjUzOUg4My4yNDAyTDg0LjkzNTUgMjMuMDUwOEg4NC45ODM0TDg2LjY4NTUgMTcuMjUzOUg4OC40NzY2TDg1Ljg3MjEgMjQuNzQ2MUM4NS4zMTg0IDI2LjMzODkgODQuNDcwNyAyNi45MDYyIDgyLjk2IDI2LjkwNjJaTTk0Ljc1ODggMjQuNjQzNkM5My4yNDEyIDI0LjY0MzYgOTIuNTIzNCAyNC4wMjgzIDkyLjUyMzQgMjIuNTM4MVYxOC41NjY0SDkxLjQ5OFYxNy4yNTM5SDkyLjUyMzRWMTUuNDE1SDk0LjI1MjlWMTcuMjUzOUg5NS41OTk2VjE4LjU2NjRIOTQuMjUyOVYyMi4zOTQ1Qzk0LjI1MjkgMjMuMTA1NSA5NC41Njc0IDIzLjMzMTEgOTUuMTg5NSAyMy4zMzExQzk1LjM1MzUgMjMuMzMxMSA5NS40ODM0IDIzLjMxMDUgOTUuNTk5NiAyMy4zMDM3VjI0LjU4MkM5NS40MDE0IDI0LjYwOTQgOTUuMTAwNiAyNC42NDM2IDk0Ljc1ODggMjQuNjQzNlpNOTkuODM3OSAyNC42NDM2Qzk3LjcwNTEgMjQuNjQzNiA5Ni4zMzExIDIzLjIyMTcgOTYuMzMxMSAyMC44NzdWMjAuODYzM0M5Ni4zMzExIDE4LjUzOTEgOTcuNzI1NiAxNy4xMDM1IDk5LjgzMTEgMTcuMTAzNUMxMDEuOTQzIDE3LjEwMzUgMTAzLjM0NSAxOC41MjU0IDEwMy4zNDUgMjAuODYzM1YyMC44NzdDMTAzLjM0NSAyMy4yMjg1IDEwMS45NjQgMjQuNjQzNiA5OS44Mzc5IDI0LjY0MzZaTTk5Ljg0NDcgMjMuMjY5NUMxMDAuOTE4IDIzLjI2OTUgMTAxLjYwOCAyMi4zOTQ1IDEwMS42MDggMjAuODc3VjIwLjg2MzNDMTAxLjYwOCAxOS4zNTI1IDEwMC45MTEgMTguNDg0NCA5OS44MzExIDE4LjQ4NDRDOTguNzcxNSAxOC40ODQ0IDk4LjA2NzQgMTkuMzU5NCA5OC4wNjc0IDIwLjg2MzNWMjAuODc3Qzk4LjA2NzQgMjIuNDAxNCA5OC43NTc4IDIzLjI2OTUgOTkuODQ0NyAyMy4yNjk1Wk0xMTEuNzA1IDI0LjY3MDlDMTA4Ljg2OCAyNC42NzA5IDEwNy4wNzcgMjIuNzE1OCAxMDcuMDc3IDE5LjU2NDVWMTkuNTU3NkMxMDcuMDc3IDE2LjQwNjIgMTA4Ljg3NSAxNC40NjQ4IDExMS43MDUgMTQuNDY0OEMxMTMuOTc1IDE0LjQ2NDggMTE1LjY0MyAxNS44Nzk5IDExNS45MDIgMTcuODg5NkwxMTUuOTA5IDE3Ljk1OEgxMTQuMThMMTE0LjE0NiAxNy44MjgxQzExMy44NTggMTYuNzAwMiAxMTIuOTkgMTUuOTk2MSAxMTEuNzA1IDE1Ljk5NjFDMTA5Ljk4MiAxNS45OTYxIDEwOC44ODIgMTcuMzcwMSAxMDguODgyIDE5LjU1NzZWMTkuNTY0NUMxMDguODgyIDIxLjc1ODggMTA5Ljk4OSAyMy4xMzk2IDExMS43MDUgMjMuMTM5NkMxMTIuOTYzIDIzLjEzOTYgMTEzLjg0NSAyMi40NjI5IDExNC4xNjYgMjEuMjQ2MUwxMTQuMTg3IDIxLjE3NzdIMTE1LjkxNkwxMTUuOTAyIDIxLjI0NjFDMTE1LjY0OSAyMy4yNjk1IDExMy45NzUgMjQuNjcwOSAxMTEuNzA1IDI0LjY3MDlaTTExNy4xMzMgMjQuNVYxNC42MzU3SDExOC44MzVWMjQuNUgxMTcuMTMzWk0xMjMuNDkgMjQuNjQzNkMxMjEuMzU3IDI0LjY0MzYgMTE5Ljk4MyAyMy4yMjE3IDExOS45ODMgMjAuODc3VjIwLjg2MzNDMTE5Ljk4MyAxOC41MzkxIDEyMS4zNzggMTcuMTAzNSAxMjMuNDgzIDE3LjEwMzVDMTI1LjU5NiAxNy4xMDM1IDEyNi45OTcgMTguNTI1NCAxMjYuOTk3IDIwLjg2MzNWMjAuODc3QzEyNi45OTcgMjMuMjI4NSAxMjUuNjE2IDI0LjY0MzYgMTIzLjQ5IDI0LjY0MzZaTTEyMy40OTcgMjMuMjY5NUMxMjQuNTcgMjMuMjY5NSAxMjUuMjYxIDIyLjM5NDUgMTI1LjI2MSAyMC44NzdWMjAuODYzM0MxMjUuMjYxIDE5LjM1MjUgMTI0LjU2MyAxOC40ODQ0IDEyMy40ODMgMTguNDg0NEMxMjIuNDI0IDE4LjQ4NDQgMTIxLjcyIDE5LjM1OTQgMTIxLjcyIDIwLjg2MzNWMjAuODc3QzEyMS43MiAyMi40MDE0IDEyMi40MSAyMy4yNjk1IDEyMy40OTcgMjMuMjY5NVpNMTMwLjU1OSAyNC42NDM2QzEyOC45NjYgMjQuNjQzNiAxMjguMDU3IDIzLjYxODIgMTI4LjA1NyAyMS45MzY1VjE3LjI1MzlIMTI5Ljc1OVYyMS42MDE2QzEyOS43NTkgMjIuNjI3IDEzMC4yMzcgMjMuMjA4IDEzMS4yMDggMjMuMjA4QzEzMi4xODYgMjMuMjA4IDEzMi44MDggMjIuNTAzOSAxMzIuODA4IDIxLjQ1MTJWMTcuMjUzOUgxMzQuNTFWMjQuNUgxMzIuODA4VjIzLjM2NTJIMTMyLjc3M0MxMzIuMzk3IDI0LjEzNzcgMTMxLjY1MiAyNC42NDM2IDEzMC41NTkgMjQuNjQzNlpNMTM4LjYwNCAyNC42NDM2QzEzNi43NzIgMjQuNjQzNiAxMzUuNjEgMjMuMjA4IDEzNS42MSAyMC44NzdWMjAuODcwMUMxMzUuNjEgMTguNTMyMiAxMzYuNzc5IDE3LjEwMzUgMTM4LjU5OCAxNy4xMDM1QzEzOS42MjMgMTcuMTAzNSAxNDAuNDUgMTcuNjI5OSAxNDAuODYgMTguNDk4SDE0MC45MDFWMTQuNjM1N0gxNDIuNjA0VjI0LjVIMTQwLjkwMVYyMy4yNjk1SDE0MC44NkMxNDAuNDUgMjQuMTI0IDEzOS42NSAyNC42NDM2IDEzOC42MDQgMjQuNjQzNlpNMTM5LjExNyAyMy4yMDhDMTQwLjE3IDIzLjIwOCAxNDAuOTAxIDIyLjI5ODggMTQwLjkwMSAyMC44NzdWMjAuODcwMUMxNDAuOTAxIDE5LjQ1NTEgMTQwLjE2MyAxOC41MzkxIDEzOS4xMTcgMTguNTM5MUMxMzguMDMgMTguNTM5MSAxMzcuMzQ3IDE5LjQyNzcgMTM3LjM0NyAyMC44NzAxVjIwLjg3N0MxMzcuMzQ3IDIyLjMxOTMgMTM4LjAyMyAyMy4yMDggMTM5LjExNyAyMy4yMDhaTTE0NC41MDQgMjQuNVYxOC41NjY0SDE0My41MTNWMTcuMjUzOUgxNDQuNTA0VjE2LjU2MzVDMTQ0LjUwNCAxNS4xNDg0IDE0NS4xNzQgMTQuNDkyMiAxNDYuNzQ2IDE0LjQ5MjJDMTQ3LjA4MSAxNC40OTIyIDE0Ny40NDMgMTQuNTE5NSAxNDcuNjU1IDE0LjU2MDVWMTUuNzVDMTQ3LjUxOSAxNS43Mjk1IDE0Ny4zMjcgMTUuNzE1OCAxNDcuMTQzIDE1LjcxNThDMTQ2LjQ3OSAxNS43MTU4IDE0Ni4xNzIgMTYuMDIzNCAxNDYuMTcyIDE2LjYxMTNWMTcuMjUzOUgxNDcuNjI4VjE4LjU2NjRIMTQ2LjIwNlYyNC41SDE0NC41MDRaTTE0OC43OTcgMjQuNVYxNC42MzU3SDE1MC40OTlWMjQuNUgxNDguNzk3Wk0xNTQuMDg4IDI0LjYyM0MxNTIuNjM5IDI0LjYyMyAxNTEuNjYxIDIzLjcyNzUgMTUxLjY2MSAyMi40MzU1VjIyLjQyMTlDMTUxLjY2MSAyMS4xMzY3IDE1Mi42NTkgMjAuMzY0MyAxNTQuNDE2IDIwLjI1NDlMMTU2LjMxIDIwLjEzODdWMTkuNjM5NkMxNTYuMzEgMTguOTA4MiAxNTUuODMxIDE4LjQ2MzkgMTU0Ljk3IDE4LjQ2MzlDMTU0LjE2MyAxOC40NjM5IDE1My42NzEgMTguODM5OCAxNTMuNTYyIDE5LjM3M0wxNTMuNTQ4IDE5LjQzNDZIMTUxLjk2OUwxNTEuOTc2IDE5LjM1MjVDMTUyLjA3OCAxOC4wNjc0IDE1My4xOTIgMTcuMTAzNSAxNTUuMDMxIDE3LjEwMzVDMTU2Ljg0MyAxNy4xMDM1IDE1OC4wMTIgMTguMDYwNSAxNTguMDEyIDE5LjUxNjZWMjQuNUgxNTYuMzFWMjMuMzg1N0gxNTYuMjY5QzE1NS44NTIgMjQuMTQ0NSAxNTUuMDQ1IDI0LjYyMyAxNTQuMDg4IDI0LjYyM1pNMTUzLjM1NiAyMi4zNjcyQzE1My4zNTYgMjIuOTYxOSAxNTMuODQ5IDIzLjMyNDIgMTU0LjU5NCAyMy4zMjQyQzE1NS41NzEgMjMuMzI0MiAxNTYuMzEgMjIuNjc0OCAxNTYuMzEgMjEuODEzNVYyMS4yNjY2TDE1NC42NTUgMjEuMzY5MUMxNTMuODE0IDIxLjQyMzggMTUzLjM1NiAyMS43ODYxIDE1My4zNTYgMjIuMzYwNFYyMi4zNjcyWk0xNTkuMzg2IDI0LjVWMTcuMjUzOUgxNjEuMDRWMTguNDYzOUgxNjEuMDc0QzE2MS4yOTMgMTcuNjE2MiAxNjEuOTI5IDE3LjEwMzUgMTYyLjc4MyAxNy4xMDM1QzE2Mi45OTUgMTcuMTAzNSAxNjMuMTkzIDE3LjEzNzcgMTYzLjMyMyAxNy4xNzE5VjE4LjcxQzE2My4xOCAxOC42NTUzIDE2Mi45MiAxOC42MTQzIDE2Mi42MzMgMTguNjE0M0MxNjEuNjY5IDE4LjYxNDMgMTYxLjA4OCAxOS4yMzYzIDE2MS4wODggMjAuMjU0OVYyNC41SDE1OS4zODZaTTE2Ny4xMSAyNC42NDM2QzE2NC45MzcgMjQuNjQzNiAxNjMuNjI0IDIzLjIwMTIgMTYzLjYyNCAyMC44OTc1VjIwLjg5MDZDMTYzLjYyNCAxOC42MDc0IDE2NC45NTcgMTcuMTAzNSAxNjcuMDI4IDE3LjEwMzVDMTY5LjEgMTcuMTAzNSAxNzAuMzg1IDE4LjU1OTYgMTcwLjM4NSAyMC43NDcxVjIxLjI5MzlIMTY1LjMxMkMxNjUuMzQgMjIuNTY1NCAxNjYuMDM3IDIzLjMxNzQgMTY3LjE0NSAyMy4zMTc0QzE2Ny45OTIgMjMuMzE3NCAxNjguNTQ2IDIyLjg2NjIgMTY4LjcxIDIyLjM4MDlMMTY4LjcyNCAyMi4zMzNIMTcwLjMxNkwxNzAuMjk2IDIyLjQwODJDMTcwLjA3IDIzLjUyMjUgMTY5LjAwNCAyNC42NDM2IDE2Ny4xMSAyNC42NDM2Wk0xNjcuMDQ5IDE4LjQzNjVDMTY2LjE0NiAxOC40MzY1IDE2NS40NyAxOS4wNDQ5IDE2NS4zMzMgMjAuMTU5MkgxNjguNzNDMTY4LjYwNyAxOS4wMTA3IDE2Ny45NTEgMTguNDM2NSAxNjcuMDQ5IDE4LjQzNjVaIgogICAgZmlsbD0idXJsKCNwYWludDJfbGluZWFyXzI2MV81ODU5KSIgLz4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl8yNjFfNTg1OSIgeDE9IjE0LjI5MiIgeTE9IjEyLjkxMDgiIHgyPSIxMy4yMTc1IiB5Mj0iMjMuNDQ4OCIKICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjRkJBRDQxIiAvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRjY2MzMiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDFfbGluZWFyXzI2MV81ODU5IiB4MT0iMzAuMTM3MSIgeTE9IjE4LjEzMDIiIHgyPSIyOS4yNDU4IiB5Mj0iMjQuMDE3OCIKICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjRkJBRDQxIiAvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRjY2MzMiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDJfbGluZWFyXzI2MV81ODU5IiB4MT0iNDQuNTMwMyIgeTE9IjI1LjMzMzMiIHgyPSIxMzkuOTA3IiB5Mj0iNzguMDcxMyIKICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjRkJBRDQxIiAvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRjY2MzMiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KPC9zdmc+)](https://deploy.workers.cloudflare.com/?url=https://github.com/DZBuild-com/dzbuild-app-starter/tree/main/cloudflare-basic) If you host on your own server instead, the Node.js example in the same [repository](https://github.com/DZBuild-com/dzbuild-app-starter) has the same flow with no dependencies and offline tests. Fill in the environment variables in `.env.example` from your console. ## 1. Register the app[​](#1-register-the-app "Direct link to 1. Register the app") Open the developer console at `https://dzbuild.com/dashboard/developer` and choose **New app** (`/dashboard/developer/apps/new`). Fill in the form: | Field | What to enter | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | The app name merchants see on the consent screen. | | Developer name | Your name or your company's name, shown on the consent screen. | | Descriptions | One description each in English, Arabic and French. | | Homepage | Your app's website, `https` only. The **Install** button on the merchant's Extensions page opens this address, or the launch URL when it is empty, so it must lead to your install flow. | | Support email | The address merchants write to for help. | | Launch URL | The `https` page that opens when a merchant clicks **Open** on your app. | | Redirect URIs | One to five exact URIs. DZBuild sends the authorization code only to these. | | Scopes | The permissions your app may request. See [Scopes](https://dzbuild.dev/scopes.md). | | Minimum plan | The lowest store plan that may use your app. Leave it on Free to allow every store. | | Webhook URL and events | Optional. See [Webhooks](https://dzbuild.dev/webhooks.md). | Save the app. The console then shows three credentials: | Credential | Format | Where it lives | | -------------- | -------------------------------------- | -------------------------------------------------------------------------------------- | | `client_id` | `dzapp_` followed by 20 hex characters | Public. It goes in the authorize URL. | | Client secret | `dzas_` followed by 48 hex characters | Shown once. Store it on your server. Generate a new one in the console if you lose it. | | Signing secret | 64 hex characters | Viewable in the console. It signs webhooks and launch tokens. | The new app is a draft. A draft runs only on stores your own account owns, which is what you need for testing. ## 2. Choose scopes[​](#2-choose-scopes "Direct link to 2. Choose scopes") Register only the scopes your app uses. On the consent screen, the merchant sees one line per resource you request. At install time you can ask for fewer scopes than you registered with the `scope` parameter, but never more. The full list is on the [Scopes](https://dzbuild.dev/scopes.md) page. ## 3. Set your redirect URIs[​](#3-set-your-redirect-uris "Direct link to 3. Set your redirect URIs") DZBuild compares the `redirect_uri` you send with your registered URIs as exact strings. A trailing slash, a different letter case or an extra query parameter makes it a different URI. A URI with a fragment (`#`) is refused when you save the app. If the `redirect_uri` does not match, DZBuild shows an error page and does not redirect at all. ## 4. Install the app on your own store[​](#4-install-the-app-on-your-own-store "Direct link to 4. Install the app on your own store") Create a PKCE verifier and its `S256` challenge. The verifier stays on your server. ``` CODE_VERIFIER=$(openssl rand -hex 32) CODE_CHALLENGE=$(printf %s "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') STATE=$(openssl rand -hex 16) ``` Send your browser to the authorize URL. Encode each value; spaces between scopes become `%20`. ``` GET https://dzbuild.com/oauth/apps/authorize?response_type=code&client_id=dzapp_0123456789abcdef0123&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&scope=store%3Aread%20orders%3Aread&state=STATE&code_challenge=CODE_CHALLENGE&code_challenge_method=S256 ``` Log in to DZBuild if asked. The consent screen lists your stores and marks the ones that cannot install the app. Pick a store and approve. DZBuild redirects to your URI: ``` HTTP/1.1 302 Found Location: https://app.example.com/callback?code=CODE&state=STATE ``` Check that `state` is the value you sent. The code is valid for 10 minutes and works once. Exchange the code for an install token. Send the form from your server, never from a browser. ``` curl -s https://dzbuild.com/oauth/apps/token \ -d grant_type=authorization_code \ -d code="$CODE" \ --data-urlencode redirect_uri=https://app.example.com/callback \ -d code_verifier="$CODE_VERIFIER" \ -d client_id="$DZ_CLIENT_ID" \ -d client_secret="$DZ_CLIENT_SECRET" ``` A successful answer looks like this: ``` { "access_token": "dzpk_live_0a1b2c3d4e5f67.9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a3928", "token_type": "Bearer", "scope": "store:read orders:read", "store_id": 141, "install_id": 7, "stores": [ { "store_id": 141, "store_name": "My test store", "install_id": 7, "access_token": "dzpk_live_0a1b2c3d4e5f67.9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a3928" } ] } ``` Save each `access_token` with its `store_id` and `install_id`. There is no `expires_in` and no refresh token: the token works until the merchant uninstalls your app. The [OAuth](https://dzbuild.dev/oauth.md) page lists every error this call can return. ## 5. Make your first call[​](#5-make-your-first-call "Direct link to 5. Make your first call") Call `GET /v1/whoami` with the install token. It needs no scope, so it works for every install. ``` curl -s https://api.dzbuild.app/v1/whoami \ -H "Authorization: Bearer $DZ_TOKEN" ``` ``` { "data": { "key_id": "dzpk_live_0a1b2c3d4e5f67", "store_id": 141, "type": "platform", "rate_limit_tier": "enterprise", "pilot": true, "scopes": ["store:read", "orders:read"], "app": { "app_id": 3, "client_id": "dzapp_0123456789abcdef0123", "install_id": 7 } }, "meta": { "request_id": "5f2c9a0b1d3e4f60", "api_version": "v1" } } ``` ## 6. Read the app object[​](#6-read-the-app-object "Direct link to 6. Read the app object") The `app` object appears only when the token belongs to an app install. It tells your server which app and which install made the call. | Field | Meaning | | ------------ | ------------------------------------------------------------------------------------------------------------------------------ | | `app_id` | Your app's numeric id on DZBuild. | | `client_id` | Your app's public client id. Compare it with your own to reject tokens issued to another app. | | `install_id` | The install this token belongs to. It stays the same if the merchant uninstalls and installs your app again on the same store. | `rate_limit_tier` is `enterprise` for every install token, whatever plan the store is on. Your real budget is the per-install limit described in [Core concepts](https://dzbuild.dev/concepts.md). ## Next steps[​](#next-steps "Direct link to Next steps") * Read [Core concepts](https://dzbuild.dev/concepts.md) before you build on install tokens. * Add [webhook verification](https://dzbuild.dev/webhooks.md) if you registered a webhook URL. * Read the [review guidelines](https://dzbuild.dev/review-guidelines.md), then submit the app for review from the console. --- # Home page sections Your app can read and change the sections of a store's home page. A section is a block on the home page with a type and settings. Ten types can be added on every theme: `category-products`, `featured`, `categories`, `banner`, `image-with-text`, `rich-text`, `trust-badges`, `testimonials`, `faq` and `video`; a section theme such as `atlas` also offers `hero` and `product-grid`. Every write is live on the store as soon as it answers. The full request and response reference is in the OpenAPI description, linked from the [API reference](https://dzbuild.dev/api-reference.md) page: `getStoreHomeLayout`, `addStoreHomeSection`, `updateStoreHomeSection`, `deleteStoreHomeSection`, `reorderStoreHomeSections` and `replaceStoreHomeLayout`. This page explains how the pieces fit together. ## Scopes[​](#scopes "Direct link to Scopes") | Scope | Allows | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `store:read` | `GET /v1/store/home-layout` | | `store:write` | `POST /v1/store/home-layout/sections`, `PATCH /v1/store/home-layout/sections/{id}`, `DELETE /v1/store/home-layout/sections/{id}`, `POST /v1/store/home-layout/reorder`, `PUT /v1/store/home-layout` | A call without the scope answers `403` with the code `forbidden` and the message `Missing scope: store:write` (or `store:read`). See [Scopes](https://dzbuild.dev/scopes.md). ## Read the layout[​](#read-the-layout "Direct link to Read the layout") `GET /v1/store/home-layout` returns: | Field | Meaning | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `theme` | The store's theme key. | | `rendered` | `false` when the store's current theme does not show home page sections. The sections are kept and show again on a theme that does. On a section theme it is `true` only while a visible `product-grid` section is saved. | | `max_sections` | 25, the most sections one home page holds. | | `cap` | The sections the store's plan allows: 3 on Free or an expired plan, 25 from Pro. Your app installs on stores of every plan, so read `cap` instead of assuming 25. | | `version` | A fingerprint of the stored layout, for `PUT`. | | `sections` | `{id, type, settings, is_active, available}` in render order, hidden sections included. `available` is `false` when the theme no longer has that type; the section is kept as stored. | | `types` | The types that can be added on this theme, each with `name`, `description`, `icon`, `limit` and `settings_schema`. | Build your forms from `settings_schema` rather than hard-coding a type: the list depends on the store's theme. Each setting has an `id`, a `type` (`checkbox`, `range`, `select`, `text`, `textarea`, `color`, `link`, `image`, `category` or `youtube`), a `default`, `min` and `max` or `options` where they apply, and `label` and `option_labels` in Arabic and French. The settings of `category-products` are `category` (a category id from `GET /v1/categories`, which needs `products:read`), `title` (up to 80 characters, empty shows the category name), `count` (4 to 12), `layout` (`grid` or `slider`) and `show_view_all` (a link to the category page). A section whose content is not filled in yet (no category or one without products, a `banner` without `image`, an `image-with-text` without `image` or without both `title` and `text`, an empty `rich-text`, `testimonials`, `faq` or `video`) is stored and the write answers `2xx`, but buyers do not see it until it is filled. `rendered` only says whether the theme shows stored sections. An `image` setting takes only the path of a picture the merchant uploaded in the dashboard for this store (`/uploads/banners/{store_id}/...`) or `""`. The API cannot upload images today, so a `banner` or an `image-with-text` needs that upload first. ## Change the layout[​](#change-the-layout "Direct link to Change the layout") Every write except `PUT` needs an `Idempotency-Key` header (see [Rate limits](https://dzbuild.dev/rate-limits.md)). ``` POST /v1/store/home-layout/sections HTTP/1.1 Host: api.dzbuild.app Authorization: Bearer dzpk_live_xxxxxxxx Idempotency-Key: hs-add-64 Content-Type: application/json {"type": "category-products", "settings": {"category": 64}, "position": 0} ``` The call answers `201` with the new section and the whole layout: ``` { "data": { "section": {"id": 418, "type": "category-products", "settings": {"category": 64, "title": "", "count": 8, "layout": "grid", "show_view_all": true}, "is_active": true, "available": true}, "sections": [ {"id": 418, "type": "category-products", "settings": {"category": 64, "title": "", "count": 8, "layout": "grid", "show_view_all": true}, "is_active": true, "available": true}, {"id": 412, "type": "category-products", "settings": {"category": 57, "title": "", "count": 8, "layout": "grid", "show_view_all": true}, "is_active": true, "available": true} ], "version": "e27a90c4b1f36d05", "change_id": 90231, "rendered": true }, "meta": { "request_id": "3b7d0e5a9c14f862", "api_version": "v1" } } ``` | Operation | Body | Notes | | --------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST .../sections` | `{type, settings?, position?}` | Unsent settings take the defaults. `position` 0 is the top; without it the section goes at the end. | | `PATCH .../sections/{id}` | `{settings?, is_active?, replace?}` | Settings are merged over the stored ones. `replace: true` resets the unsent ones to their defaults. `is_active: false` hides the section. | | `DELETE .../sections/{id}` | none | Answers `deleted: true` and the section `id`. | | `POST .../reorder` | `{ids}` | Every section id of the page exactly once, hidden ones included. | | `PUT /v1/store/home-layout` | `{sections, version?}` | The whole list, 25 items at most. An item with an `id` keeps that section, an item without one creates a section, a section left out is deleted. `settings` is the whole object here: an omitted setting goes back to its default. | Every write answers the whole layout in render order with the new `version`, a `change_id` (`null` when nothing changed) and `rendered`. Keep that answer. Through `api.dzbuild.app` every `GET` is served fresh, so a `GET` sent right after a write returns the new layout. To make sure nobody changed the page between your read and your write, send the `version` you read with `PUT`. If the layout moved, the call answers `409 write_conflict` and writes nothing; `error` then carries the current `sections` and `version` to retry from. ## Undo[​](#undo "Direct link to Undo") Install tokens cannot call `/v1/changes` or `POST /v1/changes/{id}/undo`: they answer `403` with `Apps cannot use this endpoint`. Each write is still recorded, so the merchant can undo it from your app's page in their dashboard. To revert inside your app, keep the `sections` you read before the change and send them back with `PUT`. A section you deleted comes back with a new id. ## Limits[​](#limits "Direct link to Limits") * 25 sections per home page, and at most `cap` for the store's plan. Hidden sections count. A store above its cap after a downgrade keeps its sections; a write that leaves more sections than the cap and more than before answers `403 plan_required`. * Each type has a `limit` in `types`, 12 for `category-products`. * 8 KB of settings per section once encoded, and 1 MB per request body. * 30 writes a minute and 5 at once per store, on top of your install's budget and the store's budget. See [Rate limits](https://dzbuild.dev/rate-limits.md). ## Answers and error codes[​](#answers-and-error-codes "Direct link to Answers and error codes") | Status | Code | Meaning | | ------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200` | | Read, changed, deleted, reordered or replaced. | | `201` | | Section added. | | `400` | `bad_request` | The body is not a JSON object, a field has the wrong type, the path id is not a positive number, or `Idempotency-Key` is missing on `POST`, `PATCH` or `DELETE`. | | `403` | `forbidden` | The token lacks `store:read` or `store:write`. | | `403` | `plan_required` | The write would leave more sections than the store's plan allows. `error` carries `plan` and `cap`. | | `404` | `section_not_found` | No section with this id on the store's home page. | | `409` | `write_conflict` | Another write changed the layout first, or the `version` sent on `PUT` is not the current one. When `error` carries `sections` and `version`, retry from them; otherwise read again. Retry with a new key. | | `413` | `payload_too_large` | The body is over 1 MB. | | `422` | `invalid_settings` | A value was refused. `error.fields` lists the refused settings, such as `settings.category`; on `PUT`, those of the first refused section only, such as `sections.2.settings.layout`. The message names them too. | | `422` | `invalid_section_type` | The type does not exist or cannot be added on this theme. | | `422` | `limit_reached` | More than 25 sections, or more of one type than its `limit`. | | `422` | `invalid_order` | Reorder `ids` miss a section or repeat one. | | `422` | `no_changes` | A `PATCH` without `settings` or `is_active`. | | `429` | `rate_limited` or `too_many_concurrent` | A per-minute budget is used up, or 5 home layout writes are already running for the store. Wait `retry_after` seconds. | See [Errors](https://dzbuild.dev/errors.md) for the codes every call can get, such as `app_uninstalled`. --- # DZBuild developer documentation DZBuild ([dzbuild.com](https://dzbuild.com)) is the e-commerce platform for Algerian merchants: online stores with cash on delivery, courier delivery across the wilayas, landing pages and WhatsApp order messages. A DZBuild app is a service you run on your own servers. A store owner installs it, and your server then reads and changes that store's data through the REST API. None of your code runs on DZBuild. For example, a catalog-export app lets a merchant download their products as a CSV file. The merchant installs the app, your server reads that store's products with the granted permissions, and your app prepares the export. The `cloudflare-catalog` starter provides this example. ## Choose a starting point[​](#choose-a-starting-point "Direct link to Choose a starting point") | You want to | Start here | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Build and test your first app | [Choose a starter](https://dzbuild.dev/apps/new), then follow [Get started](https://dzbuild.dev/getting-started.md) to register and install it. | | Work with a coding agent | [Connect your agent](https://dzbuild.dev/agents/connect), then follow [Build with AI](https://dzbuild.dev/build-with-ai.md). The connection gives it read-only access to the documentation. | | Find an API operation or its permissions | Use the [API reference](https://dzbuild.dev/api-reference.md) and [Scopes](https://dzbuild.dev/scopes.md). | ## What the platform offers[​](#what-the-platform-offers "Direct link to What the platform offers") | Part | What it gives your app | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | Apps | You register an app in the developer console. It gets a `client_id`, a client secret and a signing secret. | | Install flow | The store owner approves your app on a consent screen. The flow is OAuth 2.0 authorization code with PKCE `S256`. | | Install tokens | Every store that installs your app gets its own bearer token, limited to the scopes the owner approved. | | REST API | `https://api.dzbuild.app/v1` covers the store, products, orders, delivery, customers, landing pages, promo codes, pixels, shipping, analytics and WhatsApp. | | Webhooks | DZBuild posts order events and `app.uninstalled` to your URL, signed with `X-DZ-Signature`. | | WhatsApp API | Your app sends the platform's approved order templates to buyers. Each message is paid from the store's WhatsApp balance. | | Launch link | The Open button in the merchant dashboard sends the merchant to your app with a signed token in `dz_launch`. | | Developer console | `https://dzbuild.com/dashboard/developer` is where you register apps, rotate secrets, test on your own store and submit for review. | ## Who it is for[​](#who-it-is-for "Direct link to Who it is for") These pages are for developers who build tools that DZBuild merchants install from their dashboard. Any DZBuild account that has a store can open the developer console. While an app is a draft or in review, it installs only on stores that the developer account owns. Once DZBuild approves it, any store owner can install it, on any plan, unless you set a minimum plan. The merchant documentation lives at [dzbuild.com/docs](https://dzbuild.com/docs/intro). It explains the dashboard from the merchant's side. ## DZShip: free shipping API[​](#dzship-free-shipping-api "Direct link to DZShip: free shipping API") [DZShip](https://freeship.dzbuild.com) is a separate, free hosted API for creating and tracking parcels with Algerian couriers. It needs no DZShip signup or API key: you supply your own courier credentials with each request. Its sandbox lets you try requests without creating real parcels. API access is free; courier delivery charges still apply. Use the [DZShip repository](https://github.com/DZBuild-com/dzship) and its [API documentation](https://github.com/DZBuild-com/dzship/blob/main/docs/endpoints.md) for the request fields and examples. The app starters and other public projects are under [DZBuild on GitHub](https://github.com/DZBuild-com). ## Where to go next[​](#where-to-go-next "Direct link to Where to go next") * [Get started](https://dzbuild.dev/getting-started.md): register an app, install it on your own store and make your first API call. * [Core concepts](https://dzbuild.dev/concepts.md): apps, installs, stores, plans, test mode, the per-install limit and install tokens. * [OAuth](https://dzbuild.dev/oauth.md): every authorize and token parameter, and every error code. * [Scopes](https://dzbuild.dev/scopes.md): what each scope allows. * [Webhooks](https://dzbuild.dev/webhooks.md): events and signature verification. * [WhatsApp API](https://dzbuild.dev/whatsapp.md): sending order templates. * [Rate limits](https://dzbuild.dev/rate-limits.md): request budgets and retries. * [API reference](https://dzbuild.dev/api-reference.md): every `/v1` endpoint and the OpenAPI description. * [Review guidelines](https://dzbuild.dev/review-guidelines.md) and [Security](https://dzbuild.dev/security.md): what DZBuild checks before it approves an app. * [Changelog](https://dzbuild.dev/changelog.md): dated changes to the platform. --- # OAuth install flow An app gets access to a store through the OAuth 2.0 authorization code flow with PKCE. The merchant approves your app on a DZBuild consent screen, your server exchanges the code for one access token per store, and each token calls the REST API for its store only. ## Endpoints[​](#endpoints "Direct link to Endpoints") | Step | Request | Who sends it | | --------------- | -------------------------------------------------------------------- | --------------------------------------------- | | Authorize | `GET https://dzbuild.com/oauth/apps/authorize` | The merchant's browser, sent by your app | | Approve or deny | `POST https://dzbuild.com/oauth/apps/approve` and `/oauth/apps/deny` | The consent form. Your app never calls these. | | Token exchange | `POST https://dzbuild.com/oauth/apps/token` | Your server | | API calls | `https://api.dzbuild.app/v1/...` | Your server, with the access token | ## The flow[​](#the-flow "Direct link to The flow") 1. Your server creates a PKCE verifier and its S256 challenge, and a random `state`. 2. It sends the merchant to the authorize URL. A merchant who is not logged in signs in first and comes back to the same URL. 3. DZBuild shows the consent screen. The merchant picks the stores and approves. 4. DZBuild redirects the browser to your `redirect_uri` with a `code` and your `state`. 5. Your server posts the code, the verifier and its client credentials to the token endpoint. 6. The response carries one access token for each installed store. ## Step 1: send the merchant to the authorize URL[​](#step-1-send-the-merchant-to-the-authorize-url "Direct link to Step 1: send the merchant to the authorize URL") | Parameter | Required | Rule | | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `response_type` | yes | Always `code`. | | `client_id` | yes | Your app's client id from the developer console: `dzapp_` followed by 20 hex characters. | | `redirect_uri` | yes | One of the redirect URIs registered on the app, character for character. No prefix or pattern matching. | | `scope` | no | Space-separated scopes. Every scope must be registered on the app and allowed for apps. Omitted or empty means all the scopes registered on the app. At most 512 characters. | | `state` | yes | 1 to 1024 characters. DZBuild returns it unchanged. Tie it to the merchant's session and check it on return. | | `code_challenge` | yes | Base64url SHA-256 of the verifier, without padding: exactly 43 characters of `A-Z a-z 0-9 - _`. | | `code_challenge_method` | yes | Always `S256`. The `plain` method is refused. | The code verifier is 43 to 128 characters from `A-Z a-z 0-9 - . _ ~`. Keep it on your server until the token exchange. It never goes to the browser. PKCE in PHP: ``` 'code', 'client_id' => 'dzapp_0123456789abcdef0123', 'redirect_uri' => 'https://app.example.com/dzbuild/callback', 'scope' => 'orders:read products:read', 'state' => bin2hex(random_bytes(16)), 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ], '', '&', PHP_QUERY_RFC3986); ``` PKCE in Node.js: ``` const crypto = require('node:crypto'); const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); // Keep the verifier on your server (session or database) until the token call. const authorizeUrl = 'https://dzbuild.com/oauth/apps/authorize?' + new URLSearchParams({ response_type: 'code', client_id: 'dzapp_0123456789abcdef0123', redirect_uri: 'https://app.example.com/dzbuild/callback', scope: 'orders:read products:read', state: crypto.randomBytes(16).toString('hex'), code_challenge: challenge, code_challenge_method: 'S256', }); ``` To test your own code, the RFC 7636 example verifier `dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk` must give the challenge `E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM`. ## Step 2: the consent screen[​](#step-2-the-consent-screen "Direct link to Step 2: the consent screen") The consent screen shows your app name, logo and developer name. An approved app carries a "verified by DZBuild" badge. An app that is not approved yet shows a test-mode notice, and only its developer can open it. The merchant sees the stores they own. Team members see no stores, because an install grants a token for the whole store. A store that cannot take the app is listed with the reason: the merchant is not the owner, the app is in test mode, the store plan is below the app's minimum plan, or the app is suspended. The merchant can approve up to 10 stores at once. The requested scopes are shown grouped by resource. The consent request stays valid for 10 minutes. ## Step 3: handle the redirect[​](#step-3-handle-the-redirect "Direct link to Step 3: handle the redirect") On approval, DZBuild answers `302` to your redirect URI: ``` HTTP/1.1 302 Found Location: https://app.example.com/dzbuild/callback?code=3f9a...64-hex...&state=c2a8a879c9434b775191caf5b17d846f ``` The code is 64 hex characters, valid for 10 minutes and usable once. Check that `state` matches what you stored before you use the code. If your registered redirect URI already has a query string, DZBuild appends its parameters with `&`. ### Authorize and consent errors[​](#authorize-and-consent-errors "Direct link to Authorize and consent errors") Errors reach you in one of two ways. Before DZBuild has matched your `client_id` and `redirect_uri`, it shows an error page to the merchant and never redirects, so a wrong link cannot send codes to an unknown address. After that, it redirects to your `redirect_uri` with an `error` parameter. The checks run in the order of this table. | Condition | Result | Returned as | | ---------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------- | | Unknown or malformed `client_id`, or `redirect_uri` not registered exactly | Error page, HTTP 400 | Page for the merchant | | App not approved and the merchant is not its developer, or app rejected or suspended | Error page, HTTP 404 | Page for the merchant | | `response_type` is not `code` | `error=unsupported_response_type` | Redirect, with `state` when `state` is valid | | `state` missing or longer than 1024 characters | `error=invalid_request` | Redirect, without `state` | | `code_challenge` not 43 base64url characters, or `code_challenge_method` not `S256` | `error=invalid_request` | Redirect, with `state` | | A scope not registered on the app, not allowed for apps, or `scope` longer than 512 characters | `error=invalid_scope` | Redirect, with `state` | | The merchant clicks Deny | `error=access_denied` | Redirect, with `state` | | No store selected | Error page, HTTP 400 | Page for the merchant | | More than 10 stores selected | Error page, HTTP 400 | Page for the merchant | | A selected store is not owned by the merchant or cannot take the app | Error page, HTTP 403 | Page for the merchant | | Consent request expired or already answered | Error page, HTTP 400 | Page for the merchant | ## Step 4: exchange the code[​](#step-4-exchange-the-code "Direct link to Step 4: exchange the code") Your server posts a form (`application/x-www-form-urlencoded`) to the token endpoint. | Field | Value | | --------------- | ----------------------------------------------------------- | | `grant_type` | `authorization_code` | | `code` | The code from the redirect. | | `redirect_uri` | The same string you sent to the authorize URL. | | `code_verifier` | The verifier behind your `code_challenge`. | | `client_id` | Your client id. | | `client_secret` | Your client secret (`dzas_` followed by 48 hex characters). | You can send `client_id` and `client_secret` as HTTP Basic credentials instead of form fields. DZBuild reads the form fields first and uses the `Authorization: Basic` header only when neither field is present. Inside Basic, form-urlencode both values as RFC 6749 section 2.3.1 requires. Send a `User-Agent` header on this request and on every API call, for example `my-app/1.0 (+https://example.com)`. `dzbuild.com` answers a `POST` without one, this token request included, with `403` and an HTML challenge page instead of JSON. Cloudflare Workers' `fetch` and several HTTP libraries send no `User-Agent` unless you set one; `curl` sends its own. ``` curl -sS https://dzbuild.com/oauth/apps/token \ -H "Accept: application/json" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=$CODE" \ --data-urlencode "redirect_uri=https://app.example.com/dzbuild/callback" \ --data-urlencode "code_verifier=$CODE_VERIFIER" \ --data-urlencode "client_id=$DZBUILD_CLIENT_ID" \ --data-urlencode "client_secret=$DZBUILD_CLIENT_SECRET" ``` A successful exchange answers `200` with `Cache-Control: no-store`: ``` { "access_token": "dzpk_live_...", "token_type": "Bearer", "scope": "orders:read products:read", "store_id": 141, "install_id": 57, "stores": [ { "store_id": 141, "store_name": "Boutique Amel", "install_id": 57, "access_token": "dzpk_live_..." }, { "store_id": 152, "store_name": "Amel Kids", "install_id": 58, "access_token": "dzpk_live_..." } ] } ``` | Field | Meaning | | ------------------------ | -------------------------------------------------------------------------------------- | | `access_token` | The token of the first entry in `stores`. | | `token_type` | Always `Bearer`. | | `scope` | The granted scopes, space-separated. Every store in the response gets the same scopes. | | `store_id`, `install_id` | The first entry in `stores`. | | `stores` | One entry per installed store: `store_id`, `store_name`, `install_id`, `access_token`. | The response has no `expires_in` and no `refresh_token`. Store every token server side, keyed by `store_id`. This per-store access token is called the install token on the other pages. ### Token errors[​](#token-errors "Direct link to Token errors") Every error is JSON with `error` and `error_description`, and `Cache-Control: no-store`. | HTTP | `error` | `error_description` | Cause | | ---- | ------------------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | 401 | `invalid_client` | `Client authentication failed` | Missing or wrong `client_id` or `client_secret`. With Basic credentials the response also carries `WWW-Authenticate: Basic realm="dzbuild"`. | | 400 | `unsupported_grant_type` | `Only authorization_code is supported` | `grant_type` is not `authorization_code`. | | 400 | `invalid_grant` | `The code is invalid, expired, already used, or does not match this request` | Unknown, expired or used code, a code issued to another app, a different `redirect_uri`, or a verifier that does not match the challenge. | | 400 | `invalid_grant` | `No approved store could be installed` | No selected store could be installed: each one failed the final check (for example a plan below the app's minimum plan) or its install failed. | | 500 | `server_error` | `The code could not be checked` or `The install could not be completed` | A platform fault. Send the merchant through the authorize step again. | DZBuild checks the client first, then `grant_type`, then claims the code. A claimed code is spent even when the `redirect_uri` or the verifier is wrong, so a failed exchange cannot be retried with the same code. Start a new authorize request. ## Multi-store installs[​](#multi-store-installs "Direct link to Multi-store installs") A merchant who owns several stores can approve up to 10 of them in one consent. The token response lists each installed store in `stores` with its own `install_id` and `access_token`. A token only reaches its own store. DZBuild checks each store again during the exchange. A store that no longer qualifies is left out, so `stores` can hold fewer stores than the merchant ticked. Read `stores`, not the consent you expected. Running the flow again for a store that already has your app updates the same install: the `install_id` stays, the scopes become the new grant, and a new token replaces the old one. The old token stops working at once. ## Calling the API[​](#calling-the-api "Direct link to Calling the API") Send the token as a Bearer header to `https://api.dzbuild.app/v1`. `GET /v1/whoami` needs no scope and shows what a token can do: ``` curl -sS https://api.dzbuild.app/v1/whoami \ -H "Authorization: Bearer $DZBUILD_ACCESS_TOKEN" ``` ``` { "data": { "key_id": "...", "store_id": 141, "type": "platform", "rate_limit_tier": "enterprise", "pilot": true, "scopes": ["orders:read", "products:read"], "app": { "app_id": 12, "client_id": "dzapp_0123456789abcdef0123", "install_id": 57 } }, "meta": { "request_id": "...", "api_version": "v1" } } ``` App tokens work on every store plan. The `enterprise` tier in `whoami` is how the API labels app tokens; your app's own limits are on the [rate limits](https://dzbuild.dev/rate-limits.md) page. ## Token lifetime and revocation[​](#token-lifetime-and-revocation "Direct link to Token lifetime and revocation") An access token has no expiry date. It works until one of these happens: * The merchant uninstalls the app. The token is revoked and calls answer `401` with the code `unauthorized` and the message `Invalid or revoked API key`. A verified webhook endpoint receives one `app.uninstalled` event. * You run the install flow again for the same store. The new token replaces the old one. While the install exists, the API refuses the token with `403` in these cases: | `error.code` | Message | When | | ------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `app_uninstalled` | `This app is no longer installed on this store` | The install is no longer active. | | `app_suspended` | `This app has been suspended by DZBuild` | DZBuild suspended or rejected the app. Calls work again after the suspension is lifted. | | `app_not_approved` | `This app is in test mode and only runs on its developer's stores` | The app has not been approved yet (draft or first review) and the store does not belong to its developer. | | `app_plan_required` | `This app requires the ... plan` | The store plan, or an expired paid plan, is below the app's minimum plan. The message names the plan. | Through `api.dzbuild.app`, the gateway passes DZBuild's answer through unchanged, so these `403` codes, a `401` and a `429` reach you with the status and body described here. When the gateway cannot check a token with DZBuild, because DZBuild is unreachable or answers with a server error, it answers `502` with the code `server_error` and the message `Key lookup failed, retry shortly`. Retry after a short wait. There is no revocation endpoint for apps in v1. Uninstalling is done by the merchant from the app page in the dashboard. Rotating the client secret in the developer console replaces it at once. Tokens already issued keep working. ## Rate limits[​](#rate-limits "Direct link to Rate limits") | Endpoint | Limit | | --------------------------- | -------------------------------------------------------------------- | | `GET /oauth/apps/authorize` | 30 requests per 300 seconds | | `POST /oauth/apps/approve` | 10 requests per 600 seconds | | `POST /oauth/apps/token` | 60 requests per 300 seconds | | REST API with an app token | 120 requests per minute per install, before the store's shared limit | The OAuth limits count per client, identified by IP address, browser headers and session. Stay within them: a client that goes past a limit can get `429` with a `Retry-After` header of up to 120 seconds. Send `Accept: application/json` on token calls so that the refusal comes back as JSON and not as a redirect. The API limits are on the [rate limits](https://dzbuild.dev/rate-limits.md) page. --- # Rate limits Every request made with an install token passes two per-minute counters: one for your install, then one for the store. Writes also need an `Idempotency-Key` header so a retry never runs twice. ## Per-install bucket[​](#per-install-bucket "Direct link to Per-install bucket") Each install of your app gets its own budget of 120 requests per minute. It is checked first, so an app that loops on one store hits its own limit before it can use up the merchant's store budget. The counter is a fixed window that resets at the start of every clock minute. Every request counts, including requests that fail. Requests refused with `429` still count too, so retrying in a tight loop does not bring the reset closer. ## Store bucket[​](#store-bucket "Direct link to Store bucket") After the install bucket, the request counts against the store's per-minute budget. That budget is shared by every key and every app on the store, including the merchant's own API keys. App install tokens get the Enterprise budget of 600 requests per minute whatever plan the store is on, unless DZBuild set a different limit for that store. The gateway at `https://api.dzbuild.app` also applies a ceiling of 600 requests per minute per store before a request reaches DZBuild's servers. It ignores a different limit that DZBuild set for a store, so such a store is still capped at 600 there. A few costly endpoints have an extra per-store budget on top of these two: | Endpoints | Limit per store | | -------------------------------------------------- | --------------------------------- | | Product image upload | 10 per minute, 3 at the same time | | Send to delivery, courier link, test and rate sync | 6 per minute, 2 at the same time | | Home page section writes | 30 per minute, 5 at the same time | ## 429 Too Many Requests[​](#429-too-many-requests "Direct link to 429 Too Many Requests") When a bucket is full the API answers `429`: ``` { "error": { "code": "rate_limited", "message": "Per-minute API limit exceeded for this app install", "retry_after": 23 }, "meta": { "request_id": "8f2c1a9d4b7e6035", "api_version": "v1" } } ``` The response also carries a `Retry-After` header with the same number of seconds. The message tells you which bucket is full: `Per-minute API limit exceeded for this app install` for yours, `Per-minute API limit exceeded for this store` for the store's. The gateway's own refusal of the store ceiling reads `Per-minute API limit exceeded for this key`. Wait `retry_after` seconds, then send the request again with the same `Idempotency-Key`. Too many costly operations running at once answer `429` with the code `too_many_concurrent` and `retry_after` of 5 seconds. ## 402 Payment Required[​](#402-payment-required "Direct link to 402 Payment Required") A `402` means the call cannot go through until the merchant pays for something. Retrying does not help. Show the merchant what to do instead. | Code | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `no_credit` | The store's WhatsApp wallet is empty (see [WhatsApp API](https://dzbuild.dev/whatsapp.md)). The merchant tops it up in the dashboard. | | `quota_exceeded` | The store reached a monthly request quota that DZBuild set for it. | ## Idempotency-Key[​](#idempotency-key "Direct link to Idempotency-Key") `POST`, `PATCH` and `DELETE` requests must carry an `Idempotency-Key` header. `GET` and `PUT` requests do not need one. ``` POST /v1/orders HTTP/1.1 Host: api.dzbuild.app Authorization: Bearer dzpk_live_xxxxxxxx Idempotency-Key: create-order-7f3a9c21 Content-Type: application/json ``` The rules, as the API applies them: * The key is at most 64 characters from `A-Z`, `a-z`, `0-9`, `_`, `-`, `:` and `.`. A missing or malformed key answers `400` with the code `bad_request`. * A key belongs to one install token. Keys of different installs never collide. * DZBuild stores the response for 24 hours. Sending the same key again with the same method, path and body returns the stored status and body without running the request again, plus the header `Idempotency-Replay: 1`. * Using the same key with a different method, path or body answers `422` with the code `idempotency_key_reuse`. The query string is not part of the comparison. * `4xx` answers are stored too. If a request failed with a `4xx` and you fix the body, send it with a new key. * `5xx` and `429` answers are not stored, so a retry with the same key runs the request again. * The response is stored when the first request finishes. Two requests sent at the same moment with the same key can both run, so do not fire parallel retries. Generate one key per operation, for example from your own job id, and reuse it for every retry of that operation. --- # Review guidelines Every app starts in test mode, where only you can install it, on stores you own. Before any other merchant can install it, DZBuild reviews it. This page lists the app statuses, what the console requires before you submit, and what the reviewer checks. ## App statuses[​](#app-statuses "Direct link to App statuses") | Status | Meaning | Who can install | Existing installs | | -------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `draft` | New app. You can edit everything. | Only you, on stores you own. | Calls from your own stores work. | | `in_review` | Submitted and waiting for a decision. The console shows the date you submitted. Editing is locked. | Only you, on stores you own. | Calls from your own stores work. If the app was approved before, every existing install keeps working. | | `approved`, listed | Shown to every store on the Extensions page. | Any store owner whose plan meets the app's minimum plan. | Work normally. | | `approved`, unlisted | Works like a listed app, but the Extensions page shows it only to stores that installed it. | Any store owner whose plan meets the app's minimum plan, through your own install link. | Work normally. | | `rejected` | DZBuild sent the reasons by email. You can edit and submit again. | Nobody, you included. | Every call gets `403 app_suspended`. | | `suspended` | DZBuild stopped the app. Editing is locked. | Nobody. | Every call gets `403 app_suspended`. The merchant cannot open the app. | You can rotate the client secret and the signing secret in every status. ## Before you submit[​](#before-you-submit "Direct link to Before you submit") The console's Submit button refuses the app with the list of what is missing until all of these are in place: * a logo in PNG, JPEG or WebP, at most 1 MB and at least 128 by 128 pixels; * a description in English, in Arabic and in French, each at least 20 characters (500 at most); * at least one redirect URI; * a launch URL on `https`; * a support email address; * at least one scope; * a verified webhook URL, when you set a webhook URL; * at least one install of the app on one of your own stores. Click an item in the missing list to go to its field. The console also holds these rules when you save: * The app name is 3 to 60 characters and must not contain "dzbuild". * The developer name is 2 to 80 characters. * Up to 5 redirect URIs, `https` only. * Changing the webhook URL clears its verification, so verify it again. * One DZBuild account can register up to 10 apps. ## What DZBuild checks[​](#what-dzbuild-checks "Direct link to What DZBuild checks") The reviewer sees your whole registration and your developer account. The review covers: * **The listing.** Name, developer name, logo, homepage, support email and the three descriptions. Merchants read these descriptions before they install, so each one should say what the app does with store data. * **Redirect URIs.** Every URI is listed, and a URI whose host differs from your homepage host is flagged. Use your own domain for both. * **Scopes.** Grouped into read and write per resource, and compared with what the descriptions say the app does. See [scopes](https://dzbuild.dev/scopes.md). * **Webhook URL.** Checked again against the platform's URL rules, together with the verification state of your test install. * **Your account.** Your identity, account history and your other apps. * **Test installs.** The stores where the app is installed. * **The security rules.** Redirect URIs, secrets, signature checks, data deletion, scraping and support, as listed on the [security](https://dzbuild.dev/security.md) page. ## Decisions[​](#decisions "Direct link to Decisions") DZBuild answers each submission by email: in French when your DZBuild account language is French, in Arabic otherwise. * **Approved.** The app becomes available to every merchant whose plan meets its minimum plan, listed or unlisted as the next section explains. * **Rejected.** The email carries the reviewer's notes. Fix the points, then submit again from the console. * **Suspended.** An approved app can be suspended, with a note that explains why. Its API calls are refused from the next request. Reply to the email to discuss it. When the suspension is lifted, the app is approved again and calls work. ## Listed or unlisted[​](#listed-or-unlisted "Direct link to Listed or unlisted") DZBuild decides whether an approved app is listed or unlisted. You do not choose it when you submit. * **Listed.** The app appears on the Extensions page of every store. * **Unlisted.** The app works like a listed one: same consent screen, tokens, API and webhooks. It does not appear on the Extensions page, so a merchant installs it through your own install link, a page of yours that sends them to the authorize URL. Once installed, the app shows on that store's Extensions page. Both get the same review. The console shows when your app is unlisted, and the approval email says so. DZBuild can switch an approved app between listed and unlisted later, without a new submission. Existing installs keep working after a switch. ## Changing an approved app[​](#changing-an-approved-app "Direct link to Changing an approved app") Saving any change to an approved app, the logo included, sends it back to `in_review` after you confirm. Stores that already installed it keep working while it waits; other merchants cannot install it until DZBuild approves the change. Group your edits into one save. Rotating a secret or verifying the webhook URL does not start a review. --- # Scopes A scope is one permission on one kind of store data. You register the scopes your app needs in the developer console, and the authorize request can ask for all of them or a subset. The access token carries exactly the scopes the merchant approved. ## Scopes an app can request[​](#scopes-an-app-can-request "Direct link to Scopes an app can request") The descriptions come from the OpenAPI description. The consent screen shows each scope in the merchant's language. | Scope | Description | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `analytics:read` | Read store analytics and KPIs. | | `customers:read` | Read customers and each customer's orders. | | `delivery:send` | Hand orders to the store's courier. | | `landing_pages:read` | Read landing pages, their sections, publish checks and AI generation status. | | `landing_pages:write` | Create, update, publish and delete landing pages and their sections. | | `orders:read` | Read orders, their items and courier shipment state. | | `orders:write` | Create orders, change their status and cancel them. | | `pixels:read` | Read tracking pixels (access tokens are never returned). | | `pixels:write` | Add, update and delete tracking pixels. | | `products:read` | Read products and categories, with each product's images, variants, input fields, offers, quantity rules and stock. | | `products:write` | Create, update and delete products and categories, with each product's images, variants, input fields, offers, quantity rules and stock. | | `promos:read` | Read promo codes. | | `promos:write` | Create, update and delete promo codes. | | `shipping:read` | Read shipping rates and settings, linked couriers, courier coverage and the wilaya and commune lists. | | `shipping:write` | Change shipping rates and settings, and link, test, unlink or sync couriers. | | `store:read` | Read the store profile, design, home page sections and themes. | | `store:write` | Update the store profile, design, home page sections and theme. | | `whatsapp:read` | Read the WhatsApp order message templates, the WhatsApp wallet balance and the message log. | | `whatsapp:send` | Send WhatsApp order messages to buyers, each paid from the store's WhatsApp wallet. | ## Rules for app scopes[​](#rules-for-app-scopes "Direct link to Rules for app scopes") * Only the scopes in the table above can be registered on an app or granted to it. Any other scope in the authorize request fails with `invalid_scope`. * Apps cannot request `ai:generate`. It starts AI generations that spend the merchant's AI credits. * Apps cannot request `usage:read`, `webhooks:read` or `webhooks:write`. Your app receives webhooks through the webhook URL on its registration, not through `/v1/webhooks`. * An omitted or empty `scope` parameter grants every scope registered on the app. * Ask for the fewest scopes your app needs. The merchant sees one line per resource on the consent screen before approving. ## Endpoints each scope opens[​](#endpoints-each-scope-opens "Direct link to Endpoints each scope opens") This is the list the OpenAPI description declares. A few writes need the read scope as well: `POST /v1/orders`, `PATCH /v1/orders/{id}` and `POST /v1/orders/{id}/cancel` need `orders:read` and `orders:write`, `POST /v1/products` and `PATCH /v1/products/{id}` need `products:read` and `products:write`, and `POST /v1/landing-pages`, `PATCH /v1/landing-pages/{id}` and `POST /v1/landing-pages/{id}/publish` need `landing_pages:read` and `landing_pages:write`. These writes answer with the updated record, and reading it needs the read scope. Without that scope the write still runs, then the call answers `403` with a message such as `Missing scope: orders:read`, and a retry with the same `Idempotency-Key` gets that stored `403` back. Register and request both scopes. | Scope | Endpoints | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `analytics:read` | `GET /v1/analytics` | | `customers:read` | `GET /v1/customers`, `GET /v1/customers/{id}`, `GET /v1/customers/{id}/orders` | | `delivery:send` | `POST /v1/orders/{id}/send-to-delivery` | | `landing_pages:read` | `GET /v1/landing-page-section-types`, `GET /v1/landing-pages`, `GET /v1/landing-pages/{id}`, `GET /v1/landing-pages/{id}/check`, `GET /v1/landing-pages/{id}/sections`, `GET /v1/landing-pages/generate/{id}` | | `landing_pages:write` | `POST /v1/landing-pages`, `PATCH /v1/landing-pages/{id}`, `DELETE /v1/landing-pages/{id}`, `POST /v1/landing-pages/{id}/publish`, `POST /v1/landing-pages/{id}/sections`, `POST /v1/landing-pages/{id}/sections/batch`, `POST /v1/landing-pages/{id}/sections/reorder`, `PATCH /v1/landing-pages/{id}/sections/{section_id}`, `DELETE /v1/landing-pages/{id}/sections/{section_id}` | | `orders:read` | `GET /v1/orders`, `GET /v1/orders/{id}` | | `orders:write` | `POST /v1/orders`, `PATCH /v1/orders/{id}`, `POST /v1/orders/{id}/cancel` | | `pixels:read` | `GET /v1/pixels` | | `pixels:write` | `POST /v1/pixels`, `PATCH /v1/pixels/{id}`, `DELETE /v1/pixels/{id}` | | `products:read` | `GET /v1/categories`, `GET /v1/categories/{id}`, `GET /v1/products`, `GET /v1/products/{id}`, `GET /v1/products/{id}/addons`, `GET /v1/products/{id}/offers`, `GET /v1/products/{id}/quantity-rules`, `GET /v1/products/{id}/stock` | | `products:write` | `POST /v1/categories`, `POST /v1/categories/reorder`, `PATCH /v1/categories/{id}`, `DELETE /v1/categories/{id}`, `POST /v1/products`, `PATCH /v1/products/{id}`, `DELETE /v1/products/{id}`, `POST /v1/products/{id}/addons`, `POST /v1/products/{id}/images`, `PATCH /v1/products/{id}/images/{image_id}`, `DELETE /v1/products/{id}/images/{image_id}`, `POST /v1/products/{id}/offers`, `POST /v1/products/{id}/quantity-rules`, `POST /v1/products/{id}/stock`, `PUT /v1/products/{id}/variants` | | `promos:read` | `GET /v1/promo-codes` | | `promos:write` | `POST /v1/promo-codes`, `PATCH /v1/promo-codes/{id}`, `DELETE /v1/promo-codes/{id}` | | `shipping:read` | `GET /v1/shipping/coverage`, `GET /v1/shipping/providers`, `GET /v1/shipping/rates`, `GET /v1/shipping/settings`, `GET /v1/wilayas`, `GET /v1/wilayas/{id}/communes` | | `shipping:write` | `POST /v1/shipping/providers`, `POST /v1/shipping/providers/default`, `POST /v1/shipping/providers/test`, `DELETE /v1/shipping/providers/{provider}`, `POST /v1/shipping/rates`, `POST /v1/shipping/rates/sync`, `PATCH /v1/shipping/settings` | | `store:read` | `GET /v1/store`, `GET /v1/store/design`, `GET /v1/store/design/fields`, `GET /v1/store/home-sections`, `GET /v1/store/home-layout`, `GET /v1/themes` | | `store:write` | `PATCH /v1/store`, `PATCH /v1/store/design`, `PATCH /v1/store/home-sections`, `PUT /v1/store/home-layout`, `POST /v1/store/home-layout/sections`, `PATCH /v1/store/home-layout/sections/{id}`, `DELETE /v1/store/home-layout/sections/{id}`, `POST /v1/store/home-layout/reorder`, `POST /v1/store/theme`, `POST /v1/store/fast-checkout-theme`, `POST /v1/store/variant-style` | | `whatsapp:read` | `GET /v1/whatsapp/templates`, `GET /v1/whatsapp/balance`, `GET /v1/whatsapp/messages` | | `whatsapp:send` | `POST /v1/orders/{id}/whatsapp` | `GET /v1/whoami` needs no scope. `GET /v1/ping` needs no token. A call without the scope it needs answers `403` with the code `forbidden` and a message that names the scope, for example `Missing scope: orders:read`. ## Endpoints closed to app tokens[​](#endpoints-closed-to-app-tokens "Direct link to Endpoints closed to app tokens") These answer `403` with the message `Apps cannot use this endpoint` whatever scopes the token holds: `/v1/keys`, `/v1/webhooks`, `/v1/changes` and `POST /v1/changes/{id}/undo`. The merchant keeps key management, webhook subscriptions and the undo history. The assistant connection endpoints `GET /v1/connection` and `POST /v1/connection/active-store` answer `403` without `store:read` or `store:write`, and `404` otherwise, because an app token was not created through an assistant connection. ## Scopes and webhooks[​](#scopes-and-webhooks "Direct link to Scopes and webhooks") Every order event (`order.created`, `order.confirmed`, `order.processing`, `order.shipped`, `order.delivered`, `order.cancelled`, `order.returned`) carries the buyer's name, phone and address. Your install receives order events only when the merchant granted `orders:read`. The `app.uninstalled` event is sent whatever the scopes. See [webhooks](https://dzbuild.dev/webhooks.md). ## What the merchant sees[​](#what-the-merchant-sees "Direct link to What the merchant sees") The consent screen groups scopes by resource and shows one line per resource: the write line when the app asks for write access, the read line otherwise. The line is written in the merchant's language. Changing the scopes registered on the app does not change tokens already issued; a store gets the new scopes when the merchant installs the app again. --- # Security requirements Every app installed on a DZBuild store holds a token to a merchant's orders, customers or catalogue. These rules apply to every app, in test mode and after approval. DZBuild can suspend an app that breaks them, and a suspended app gets `403 app_suspended` on every call. ## Redirect URIs[​](#redirect-uris "Direct link to Redirect URIs") Register 1 to 5 redirect URIs in the developer console. Each one must: * use `https`, * be at most 512 characters, * carry no user name, password or `#fragment`, * contain no `*` wildcard. DZBuild compares the `redirect_uri` of each request with the registered list character for character. `https://app.example.com/callback` and `https://app.example.com/callback/` are two different URIs. The console accepts `https` only, so test the install flow on a `workers.dev` address or through an https tunnel and register its address. Generate a new random `state` for every authorize request, tie it to the merchant's session, and refuse a callback whose `state` does not match. ## Client secret and access tokens[​](#client-secret-and-access-tokens "Direct link to Client secret and access tokens") The client secret (`dzas_` followed by 48 hex characters) is shown once, when the app is created or the secret is rotated. DZBuild keeps only a hash of it. Use it on your server only: never in a browser, a mobile app, a public repository or a log line. If the secret leaks, rotate it in the developer console. The old secret stops working at once, and access tokens already issued keep working. Access tokens (`dzpk_live_...`) give the API access the merchant approved, with no expiry. Keep them server side, encrypted at rest, one per store. Apps have no endpoint to revoke a token. If a token leaks, ask the merchant to install your app again on that store: the new token replaces the old one at once. The merchant can also uninstall the app, which revokes it. ## Verify webhook signatures[​](#verify-webhook-signatures "Direct link to Verify webhook signatures") Every webhook DZBuild sends to your app is signed with the app's signing secret, a 64-character hex string you can view and rotate in the console. The request carries `X-DZ-Timestamp`, `X-DZ-Event`, `X-DZ-Delivery` and: ``` X-DZ-Signature: t=1758880000,v1=5d41402abc4b2a76b9719d911017c592... ``` Compute HMAC-SHA256 with the signing secret over the timestamp, a dot and the raw request body, compare it with `v1` in constant time, and reject a request whose timestamp is more than 5 minutes old. App endpoints never receive the `X-DZ-Token` header, so the signature is the only proof that a request came from DZBuild. The [webhooks](https://dzbuild.dev/webhooks.md) page has the full scheme and code in several languages. One signing secret covers every store that installed your app. Rotating it re-keys all of them in one step. ## Verify launch tokens[​](#verify-launch-tokens "Direct link to Verify launch tokens") When the merchant opens your app from the DZBuild dashboard, DZBuild redirects the browser to your launch URL with a `dz_launch` query parameter. The launch URL must use `https`. The parameter is a JWT signed with HS256, using the app's signing secret: the 64-character string exactly as the console shows it. | Claim | Value | | ------------ | ----------------------------------------------------------- | | `iss` | `dzbuild` | | `aud` | Your `client_id`. | | `sub` | The id of the DZBuild user who opened the app, as a string. | | `store_id` | The store the app was opened from. | | `install_id` | Your install on that store. | | `is_owner` | `true` for the store owner, `false` for a team member. | | `iat` | Issue time, Unix seconds. | | `exp` | `iat` plus 300 seconds. | | `jti` | 16 random hex characters. | Accept the token only when the signature matches, the header says `HS256`, `iss` is `dzbuild`, `aud` is your client id and `exp` has not passed. Then refuse any `jti` you have already accepted in the last 5 minutes. After reading the token, redirect to a URL without it so it stays out of browser history and logs. A launch token proves who opened the app and from which store. It does not call the API: use the access token you stored for that `store_id` and `install_id`. In PHP: ``` base64_decode(strtr($s, '-_', '+/'), true); $expected = rtrim(strtr(base64_encode(hash_hmac('sha256', "$head.$body", $signingSecret, true)), '+/', '-_'), '='); if (!hash_equals($expected, $sig)) { return null; } $header = json_decode((string) $b64($head), true); $claims = json_decode((string) $b64($body), true); if (($header['alg'] ?? '') !== 'HS256' || !is_array($claims)) { return null; } $now = time(); if (($claims['iss'] ?? '') !== 'dzbuild' || ($claims['aud'] ?? '') !== $clientId || !is_int($claims['exp'] ?? null) || $claims['exp'] < $now || ($claims['iat'] ?? 0) > $now + 60) { return null; } return $claims; // Then refuse a jti you have already seen in the last 5 minutes. } ``` In Node.js: ``` const crypto = require('node:crypto'); // Claims of a valid dz_launch token, or null. clientId is your app's client_id. function verifyLaunchToken(jwt, signingSecret, clientId) { const parts = String(jwt).split('.'); if (parts.length !== 3) return null; const [head, body, sig] = parts; const expected = crypto.createHmac('sha256', signingSecret).update(`${head}.${body}`).digest(); const given = Buffer.from(sig, 'base64url'); if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null; const header = JSON.parse(Buffer.from(head, 'base64url').toString('utf8')); const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); const now = Math.floor(Date.now() / 1000); if (header.alg !== 'HS256' || claims.iss !== 'dzbuild' || claims.aud !== clientId) return null; if (!Number.isInteger(claims.exp) || claims.exp < now || claims.iat > now + 60) return null; return claims; // Then refuse a jti you have already seen in the last 5 minutes. } ``` ## Delete store data after uninstall[​](#delete-store-data-after-uninstall "Direct link to Delete store data after uninstall") When a merchant uninstalls your app, DZBuild revokes the store's token, drops the webhook deliveries still waiting for that install, and sends one `app.uninstalled` event to your webhook URL when it is verified and active: ``` { "id": "evt_...", "event": "app.uninstalled", "created_at": "2026-09-26T10:15:00+01:00", "store_id": 141, "data": { "install_id": 57, "client_id": "dzapp_0123456789abcdef0123", "store_id": 141, "uninstalled_at": "2026-09-26T09:15:00+00:00" } } ``` Delete the data you hold for that store within 30 days of the event: orders, customers, products and every copy of the access token. If your app has no verified webhook URL, a `401` on the store's token is your signal. ## Ask for the fewest scopes[​](#ask-for-the-fewest-scopes "Direct link to Ask for the fewest scopes") Request only the [scopes](https://dzbuild.dev/scopes.md) your features use: no write scope on data you only read, no `customers:read` for an app that never shows a customer. DZBuild compares the requested scopes with what your listing says the app does, and the merchant sees one line per resource on the consent screen before approving. ## No scraping[​](#no-scraping "Direct link to No scraping") Reach store data only through the REST API and webhooks. Do not script the DZBuild dashboard, sign in as the merchant, or scrape dashboard or storefront pages. Never ask a merchant for their DZBuild password or for a merchant API key. ## Answer support email[​](#answer-support-email "Direct link to Answer support email") Register a support email address that you read, and answer merchants who write to it. The consent screen and the app page in the merchant dashboard show it. DZBuild sends review decisions to your account email, not to this address. --- # Webhooks DZBuild sends an HTTPS `POST` to your app's webhook URL when something happens on a store that installed your app. Every request is signed with your app's signing secret, so your server can prove it came from DZBuild before acting on it. ## Webhook URL[​](#webhook-url "Direct link to Webhook URL") You set one webhook URL and the events you want on your app in the [developer console](https://dzbuild.com/dashboard/developer). The same URL receives the events of every store that installs the app, and the `store_id` field in each payload tells you which store it is about. The URL must meet these rules, or the console refuses to save it: * `https` only, on port 443. * A public host name with a top-level domain. IP addresses, user names and passwords in the URL are refused. * At most 255 characters. * Not on a DZBuild domain (`dzbuild.app`, `dzbuild.com`, `dzbuild.me`, `dzme.app`, `minacef.com`, `twarc.net`) and not on `workers.dev`. The last rule means a Worker on a free `workers.dev` URL cannot receive webhooks. Put it on a domain you own on your Cloudflare account (custom domains are available on Cloudflare's free plan), register that URL and verify it. If you only need new orders, poll instead: one `GET /v1/orders?since=&limit=50` per store per minute stays inside the 120 requests per minute of an install token, and it is what the [cloudflare-orders](https://github.com/DZBuild-com/dzbuild-app-starter/blob/main/cloudflare-orders/README.md) preset does by default. Status changes still need webhooks. When you press Verify in the console, DZBuild sends one `webhook.verify` request to the URL. Answer it with any `2xx` status within 6 seconds. Your URL receives real events only after it is verified. A successful verify also turns delivery back on for every install whose endpoint was disabled after repeated failures. Order events reach an install only when the merchant granted the `orders:read` scope, because the payload carries the buyer's name, phone and address. Without that scope the install gets no order events, only `app.uninstalled`. ## Events[​](#events "Direct link to Events") | Event | Sent when | | ------------------ | -------------------------------------------------------------------------------------------- | | `order.created` | A new order is placed on the store. | | `order.confirmed` | The order status changes to confirmed. | | `order.processing` | The order status changes to processing. | | `order.shipped` | The order status changes to shipped. | | `order.delivered` | The order status changes to delivered. | | `order.cancelled` | The order status changes to cancelled. | | `order.returned` | The order status changes to returned. | | `app.uninstalled` | The merchant uninstalls your app from the store. You receive it even if you did not pick it. | | `webhook.verify` | You press Verify in the developer console. | DZBuild checks stores for new orders and status changes once a minute. `order.created` goes out on the first check that sees the new order, so expect it within a minute or two. Each status event is sent at most once per order. ## Payload[​](#payload "Direct link to Payload") Every request body is one JSON envelope. This order example is shortened: ``` { "id": "evt_9b2f6c1d04e8a7b35c6d2e10", "event": "order.confirmed", "created_at": "2026-09-26T14:05:09+01:00", "store_id": 1234, "data": { "order": { "id": 98765, "order_number": "ORD-1234-20260926-1a2b3c4d", "status": "confirmed" }, "items": [ { "product_id": 555, "product_name": "Sac en cuir", "quantity": 1 } ], "previous_status": "pending" } } ``` | Field | Meaning | | ------------ | ------------------------------------------------------------------------------------------- | | `id` | Event id. It stays the same on every retry of the same event, so use it to drop duplicates. | | `event` | The event name. | | `created_at` | When the envelope was built, ISO 8601 with a UTC offset. | | `store_id` | The store the event belongs to. It is `0` on `webhook.verify`. | | `data` | The event data. | For order events, `data.order` carries these fields: `id`, `store_id`, `order_number`, `store_seq`, `status`, `payment_status`, `payment_method`, `delivery_type`, `desk_name`, `customer_name`, `customer_phone`, `customer_email`, `wilaya_id`, `wilaya_name` (Arabic name), `commune`, `address`, `subtotal`, `shipping_cost`, `discount`, `total`, `promo_code`, `customer_notes`, `tracking_number`, `delivery_company`, `created_at`, `updated_at`. `data.items` is a list with `product_id`, `variant_id`, `product_name`, `variant_name`, `sku`, `price`, `quantity`, `total` and `source` per line. Status events also carry `data.previous_status`. ## Delivery headers[​](#delivery-headers "Direct link to Delivery headers") | Header | Value | | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `X-DZ-Timestamp` | Unix time in seconds when the request was signed. | | `X-DZ-Signature` | `t=,v1=` | | `X-DZ-Event` | The event name, the same as `event` in the body. | | `X-DZ-Delivery` | The delivery id. It stays the same across retries of one delivery. The `webhook.verify` request from the console has none. | | `User-Agent` | `DZBuild-Webhooks/1.0` | ## Signature[​](#signature "Direct link to Signature") `X-DZ-Signature` has two parts separated by a comma: * `t` is the Unix timestamp, the same value as `X-DZ-Timestamp`. * `v1` is the lowercase hex HMAC-SHA256 of the string `t` + `.` + the raw request body, keyed with your app's signing secret. To verify a request: 1. Read the raw body bytes before any JSON parsing. A body that was parsed and serialized again will not match. 2. Compute HMAC-SHA256 with your signing secret over the timestamp, a dot, and the raw body. 3. Compare the result with `v1` in constant time. 4. Reject the request if the timestamp is more than 5 minutes away from your clock. This stops an old request from being replayed. Your signing secret is shown in the developer console. Keep it on your server only. When you rotate it, every install signs with the new secret from the next delivery on, with no overlap, so deploy the new secret to your server at the same moment. ### Node.js[​](#nodejs "Direct link to Node.js") ``` const crypto = require('crypto'); const express = require('express'); const app = express(); const SECRET = process.env.DZBUILD_SIGNING_SECRET; function parseSignature(header) { const parts = {}; for (const pair of (header || '').split(',')) { const i = pair.indexOf('='); if (i > 0) parts[pair.slice(0, i)] = pair.slice(i + 1); } return parts; } app.post('/dzbuild/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const { t, v1 } = parseSignature(req.get('X-DZ-Signature')); if (!t || !v1 || !/^\d+$/.test(t)) return res.status(401).end(); const expected = crypto.createHmac('sha256', SECRET).update(t + '.').update(req.body).digest('hex'); const valid = v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); if (!valid) return res.status(401).end(); if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(401).end(); const event = JSON.parse(req.body.toString('utf8')); // Queue event for processing, then answer fast. res.status(200).end(); }); ``` ### PHP[​](#php "Direct link to PHP") ``` 300) { http_response_code(401); exit; } $event = json_decode($raw, true); // Queue $event for processing, then answer fast. http_response_code(200); ``` ### Python[​](#python "Direct link to Python") ``` import hashlib import hmac import json import os import time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["DZBUILD_SIGNING_SECRET"].encode() @app.post("/dzbuild/webhooks") def dzbuild_webhook(): raw = request.get_data() parts = dict( pair.split("=", 1) for pair in request.headers.get("X-DZ-Signature", "").split(",") if "=" in pair ) t = parts.get("t", "") v1 = parts.get("v1", "") expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest() if not t.isdigit() or not hmac.compare_digest(expected, v1): abort(401) if abs(time.time() - int(t)) > 300: abort(401) event = json.loads(raw) # Queue event for processing, then answer fast. return "", 200 ``` ## Retries and dead-lettering[​](#retries-and-dead-lettering "Direct link to Retries and dead-lettering") A delivery succeeds when your server answers with a `2xx` status within 10 seconds. Any other status, a redirect (`3xx`), a timeout or a connection error counts as a failed attempt. Redirects are not followed. Each delivery gets up to 5 attempts. After a failed attempt, the next one waits at least: | Failed attempt | Wait before the next attempt | | -------------- | ------------------------------------------------------- | | 1 | 60 seconds | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | None. The delivery is marked dead and never sent again. | The waits are minimums because the dispatcher runs once a minute. Delivery is at least once: a request can arrive twice, for example after a network failure on DZBuild's side, so drop repeats by the envelope `id`. After 10 failed attempts in a row across deliveries, the endpoint of that install is disabled and its pending deliveries are marked dead. Fix your server, then press Verify in the developer console to turn delivery back on. Deliveries that were already dead are not sent again. Pending deliveries are also dropped, never sent, when DZBuild suspends your app, when the store's plan falls below your app's minimum plan, or when a test-mode app runs on a store that is not the developer's. `app.uninstalled` is the exception and is still sent. ## app.uninstalled[​](#appuninstalled "Direct link to app.uninstalled") When a merchant uninstalls your app, DZBuild revokes the install token, drops every pending delivery of that install, and sends one `app.uninstalled` event if the install's endpoint is active and verified: ``` { "id": "evt_5a0c7e3b9d1f24681ace3579", "event": "app.uninstalled", "created_at": "2026-09-26T14:05:09+01:00", "store_id": 1234, "data": { "install_id": 57, "client_id": "dzapp_4e1b9c07d2a86f35e0b1", "store_id": 1234, "uninstalled_at": "2026-09-26T13:05:09+00:00" } } ``` `uninstalled_at` is in UTC. The notice follows the same retry schedule, and the endpoint stays active for one day after the uninstall so the retries can finish. If the merchant installs your app again on that store before the notice is delivered, the pending notice is dropped so it cannot cancel the new install. After the uninstall, API calls with the old install token answer `401`. Handle the store's data as the [security rules](https://dzbuild.dev/security.md) describe. ## No X-DZ-Token for apps[​](#no-x-dz-token-for-apps "Direct link to No X-DZ-Token for apps") Merchant webhooks created in the DZBuild dashboard also carry an `X-DZ-Token` header, a plain copy of the secret for no-code tools. App deliveries never carry it: one signing secret covers every store that installs your app, and a copy in a request log would let anyone forge events for all of them. Always verify `X-DZ-Signature`. --- # WhatsApp API Your app can send WhatsApp messages to a store's buyers through four endpoints. The messages use the order templates that Meta approved for DZBuild, and each one is paid from the store's WhatsApp wallet, exactly like the messages the WhatsApp Sender addon sends on its own. You cannot send free text or your own templates. The full request and response reference is in the OpenAPI description, linked from the [API reference](https://dzbuild.dev/api-reference.md) page. This page explains how the pieces fit together. ## Scopes[​](#scopes "Direct link to Scopes") | Scope | Allows | | --------------- | ------------------------------------------------------------------------------------- | | `whatsapp:read` | `GET /v1/whatsapp/templates`, `GET /v1/whatsapp/balance`, `GET /v1/whatsapp/messages` | | `whatsapp:send` | `POST /v1/orders/{id}/whatsapp` | Request only the scopes you need in the authorize URL. A call without the scope answers `403` with the code `forbidden` and the message `Missing scope: whatsapp:send` (or `whatsapp:read`). See [Scopes](https://dzbuild.dev/scopes.md). ## Templates[​](#templates "Direct link to Templates") `GET /v1/whatsapp/templates` lists the six templates. It reads the approval status DZBuild keeps and never calls Meta. | Key | Sent for | | ----------------- | ------------------------------------------------ | | `received` | The order was received. | | `confirmed` | The order was confirmed. | | `shipped_home` | The parcel is on its way to the buyer's address. | | `shipped_desk` | The parcel is on its way to a courier desk. | | `delivery_failed` | The delivery attempt failed. | | `desk_ready` | The parcel is ready for pickup. | Each item carries `key`, `name` (the template name at Meta, for example `dz_order_confirmed`), `toggle` (the merchant's matching switch in the addon) and `languages`. `languages.ar` and `languages.fr` each hold the `body` with its placeholders, an `example`, and the `status`. A template can only be sent in a language whose status is `APPROVED`. `UNKNOWN` means DZBuild has no status stored yet. ## Wallet[​](#wallet "Direct link to Wallet") `GET /v1/whatsapp/balance` returns: | Field | Meaning | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `balance` | Messages left in the store's wallet. One message costs one credit. | | `low_balance` | `true` when fewer than 50 messages are left. | | `addon_active` | Whether the merchant has the WhatsApp Sender addon turned on. | | `stats` | Counts for the last 30 days by status (`sent`, `delivered`, `read`, `failed`) and `used_month`, the credits used since the first day of this month. | The merchant tops up the wallet in the DZBuild dashboard. Your app cannot add credit. ## Send a message[​](#send-a-message "Direct link to Send a message") `POST /v1/orders/{id}/whatsapp` queues one template for one order of the store. It is a write, so it needs an `Idempotency-Key` header (see [Rate limits](https://dzbuild.dev/rate-limits.md)). ``` POST /v1/orders/98765/whatsapp HTTP/1.1 Host: api.dzbuild.app Authorization: Bearer dzpk_live_xxxxxxxx Idempotency-Key: wa-98765-confirmed Content-Type: application/json {"template": "confirmed", "language": "fr"} ``` | Body field | Required | Meaning | | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `template` | Yes | One of the six keys, or `shipped`. `shipped` picks the right template from the order's delivery type: `shipped_home` for home delivery, `shipped_desk` for a desk, `desk_ready` for pickup. | | `language` | No | `ar` or `fr`. Without it, the language set in the store's WhatsApp Sender is used, or the store's language when the addon has none. When that fallback is neither `ar` nor `fr`, the message goes in Arabic. | A manual send ignores the merchant's automatic switches in the addon. The addon itself must be active on the store. The call answers `202` when the message is queued and the credit is taken: ``` { "data": { "message_id": 4411, "status": "queued", "template": "confirmed", "language": "fr" }, "meta": { "request_id": "8f2c1a9d4b7e6035", "api_version": "v1" } } ``` DZBuild sends queued messages in the background. Follow the result with `GET /v1/whatsapp/messages`. ## Answers and error codes[​](#answers-and-error-codes "Direct link to Answers and error codes") | Status | Code | Meaning | | ------ | ----------------------- | --------------------------------------------------------------------------------------------------------------- | | `202` | | Queued, one credit taken. | | `400` | `bad_request` | The order id is not numeric, or the body is not valid JSON. | | `402` | `no_credit` | The store's wallet is empty. Nothing was queued and nothing was charged. | | `403` | `addon_not_active` | The merchant has not turned on the WhatsApp Sender addon. | | `403` | `forbidden` | The token lacks `whatsapp:send`. | | `404` | `not_found` | No order with this id on this store. | | `409` | `already_sent` | This template was already sent for this order. The error object carries the existing message `id` and `status`. | | `422` | `unknown_template` | `template` is not one of the accepted keys. | | `422` | `invalid_language` | `language` is not `ar` or `fr`. | | `422` | `invalid_number` | The buyer's phone is not a valid Algerian mobile number. | | `422` | `suppressed` | The buyer's number is blocked for WhatsApp messages on the platform. | | `422` | `template_not_approved` | The template is not approved in that language. | | `422` | `empty_param` | A value the template needs is empty on the order. | | `500` | `send_failed` | The message could not be queued. Retry later. | Each template can be sent once per order through the API. A later call for the same template and order answers `409`, even if the first message failed. The `422` answers from `invalid_number` to `empty_param` are different: nothing was charged, and the next call for that template and order tries again, so fix the cause (for example the phone number on the order) and call again with a new `Idempotency-Key`. ## Message log[​](#message-log "Direct link to Message log") `GET /v1/whatsapp/messages` lists the store's messages, newest first. Query parameters: `order_id` to filter one order, `limit` (default 50, maximum 200) and `cursor` (the `next_cursor` of the previous page). The buyer's phone number is never returned. | Field | Meaning | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | The message id, the same as `message_id` in the send answer. | | `order_id` | The order. | | `event` | The template key for API messages, or the addon event (`received`, `confirmed`, `shipped`, `delivery_failed`, `desk_ready`) for automatic ones. | | `source` | `api` for messages sent through the API, `auto` for the addon's automatic messages. | | `status` | `queued`, `sending`, `sent`, `delivered`, `read`, `failed` or `skipped`. | | `language` | `ar` or `fr`. | | `template_name` | The template name at Meta. | | `error_title` | Why the message failed or was skipped, or `null`. | | `refunded` | `true` when the credit was given back. | | `billing` | `charged` once WhatsApp billed the message, `free` once the credit came back, `null` while WhatsApp has not reported the outcome. | | `created_at` | When the message was queued. | A message that fails, expires undelivered after 30 days, or is delivered without a charge from WhatsApp gets its credit back once: `refunded` turns `true` and `billing` becomes `free`. A message WhatsApp bills settles as `charged`. ---