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
You set one webhook URL and the events you want on your app in the developer console. 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:
httpsonly, 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 onworkers.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=<last created_at>&limit=50 per store per minute stays inside the 120 requests per minute of an install token, and it is what the cloudflare-orders 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
| 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
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
| Header | Value |
|---|---|
Content-Type | application/json |
X-DZ-Timestamp | Unix time in seconds when the request was signed. |
X-DZ-Signature | t=<timestamp>,v1=<signature> |
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
X-DZ-Signature has two parts separated by a comma:
tis the Unix timestamp, the same value asX-DZ-Timestamp.v1is the lowercase hex HMAC-SHA256 of the stringt+.+ the raw request body, keyed with your app's signing secret.
To verify a request:
- Read the raw body bytes before any JSON parsing. A body that was parsed and serialized again will not match.
- Compute HMAC-SHA256 with your signing secret over the timestamp, a dot, and the raw body.
- Compare the result with
v1in constant time. - 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
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
$secret = getenv('DZBUILD_SIGNING_SECRET');
$raw = file_get_contents('php://input');
$parts = [];
foreach (explode(',', $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '') as $pair) {
[$k, $v] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!ctype_digit($t) || !hash_equals($expected, $v1) || abs(time() - (int) $t) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Queue $event for processing, then answer fast.
http_response_code(200);
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
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
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 describe.
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.