# 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=<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](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=<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[​](#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")

```
<?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[​](#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`.
