Skip to main content

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:

  • 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=<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​

EventSent when
order.createdA new order is placed on the store.
order.confirmedThe order status changes to confirmed.
order.processingThe order status changes to processing.
order.shippedThe order status changes to shipped.
order.deliveredThe order status changes to delivered.
order.cancelledThe order status changes to cancelled.
order.returnedThe order status changes to returned.
app.uninstalledThe merchant uninstalls your app from the store. You receive it even if you did not pick it.
webhook.verifyYou 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"
}
}
FieldMeaning
idEvent id. It stays the same on every retry of the same event, so use it to drop duplicates.
eventThe event name.
created_atWhen the envelope was built, ISO 8601 with a UTC offset.
store_idThe store the event belongs to. It is 0 on webhook.verify.
dataThe 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​

HeaderValue
Content-Typeapplication/json
X-DZ-TimestampUnix time in seconds when the request was signed.
X-DZ-Signaturet=<timestamp>,v1=<signature>
X-DZ-EventThe event name, the same as event in the body.
X-DZ-DeliveryThe delivery id. It stays the same across retries of one delivery. The webhook.verify request from the console has none.
User-AgentDZBuild-Webhooks/1.0

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​

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 attemptWait before the next attempt
160 seconds
25 minutes
330 minutes
42 hours
5None. 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.

This page for AI toolsView as MarkdownOpen in ChatGPTOpen in Claude