# Webhooks

DZBuild envoie une requête HTTPS `POST` à l'URL de webhook de votre application quand un événement se produit sur une boutique qui l'a installée. Chaque requête est signée avec le secret de signature de votre application, ce qui permet à votre serveur de vérifier qu'elle vient bien de DZBuild avant d'agir.

## URL de webhook[​](#url-de-webhook "Lien direct vers URL de webhook")

Vous définissez une URL de webhook et les événements voulus sur votre application dans la [console développeur](https://dzbuild.com/dashboard/developer). La même URL reçoit les événements de toutes les boutiques qui installent l'application, et le champ `store_id` de chaque payload indique la boutique concernée.

L'URL doit respecter ces règles, sinon la console refuse de l'enregistrer :

* `https` uniquement, sur le port 443.
* Un nom d'hôte public avec un domaine de premier niveau. Les adresses IP, identifiants et mots de passe dans l'URL sont refusés.
* 255 caractères au maximum.
* Pas sur un domaine DZBuild (`dzbuild.app`, `dzbuild.com`, `dzbuild.me`, `dzme.app`, `minacef.com`, `twarc.net`) ni sur `workers.dev`.

La dernière règle signifie qu'un Worker sur une URL `workers.dev` gratuite ne peut pas recevoir de webhooks. Placez-le sur un domaine que vous possédez dans votre compte Cloudflare (les domaines personnalisés sont disponibles avec l'offre gratuite de Cloudflare), enregistrez cette URL, puis vérifiez-la.

S'il ne vous faut que les nouvelles commandes, interrogez l'API à la place : un `GET /v1/orders?since=<last created_at>&limit=50` par boutique et par minute reste dans les 120 requêtes par minute d'un jeton d'installation, et c'est ce que fait le preset [cloudflare-orders](https://github.com/DZBuild-com/dzbuild-app-starter/blob/main/cloudflare-orders/README.md) par défaut. Les changements de statut, eux, passent toujours par les webhooks.

Quand vous cliquez sur « Vérifier le webhook » dans la console, DZBuild envoie une requête `webhook.verify` à l'URL. Répondez avec n'importe quel statut `2xx` en moins de 6 secondes. Votre URL ne reçoit de vrais événements qu'une fois vérifiée. Une vérification réussie réactive aussi la livraison pour chaque installation dont l'endpoint avait été désactivé après des échecs répétés.

Les événements de commande n'arrivent à une installation que si le marchand a accordé le scope `orders:read`, car le payload contient le nom, le téléphone et l'adresse de l'acheteur. Sans ce scope, l'installation ne reçoit aucun événement de commande, seulement `app.uninstalled`.

## Événements[​](#événements "Lien direct vers Événements")

| Événement          | Envoyé quand                                                                                        |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| `order.created`    | Une nouvelle commande est passée sur la boutique.                                                   |
| `order.confirmed`  | Le statut de la commande passe à confirmé.                                                          |
| `order.processing` | Le statut de la commande passe à en préparation.                                                    |
| `order.shipped`    | Le statut de la commande passe à expédié.                                                           |
| `order.delivered`  | Le statut de la commande passe à livré.                                                             |
| `order.cancelled`  | Le statut de la commande passe à annulé.                                                            |
| `order.returned`   | Le statut de la commande passe à retourné.                                                          |
| `app.uninstalled`  | Le marchand désinstalle votre application de la boutique. Vous le recevez même sans l'avoir choisi. |
| `webhook.verify`   | Vous cliquez sur « Vérifier le webhook » dans la console développeur.                               |

DZBuild vérifie les nouvelles commandes et les changements de statut une fois par minute. `order.created` part au premier passage qui voit la nouvelle commande : comptez une à deux minutes. Chaque événement de statut est envoyé au plus une fois par commande.

## Payload[​](#payload "Lien direct vers Payload")

Chaque corps de requête est une enveloppe JSON. Cet exemple de commande est abrégé :

```
{

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

  }

}
```

| Champ        | Signification                                                                                                                       |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `id`         | Identifiant de l'événement. Il reste le même à chaque nouvelle tentative du même événement : utilisez-le pour écarter les doublons. |
| `event`      | Le nom de l'événement.                                                                                                              |
| `created_at` | Date de création de l'enveloppe, au format ISO 8601 avec décalage UTC.                                                              |
| `store_id`   | La boutique concernée. Il vaut `0` sur `webhook.verify`.                                                                            |
| `data`       | Les données de l'événement.                                                                                                         |

Pour les événements de commande, `data.order` contient ces champs : `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` (nom en arabe), `commune`, `address`, `subtotal`, `shipping_cost`, `discount`, `total`, `promo_code`, `customer_notes`, `tracking_number`, `delivery_company`, `created_at`, `updated_at`. `data.items` est une liste avec `product_id`, `variant_id`, `product_name`, `variant_name`, `sku`, `price`, `quantity`, `total` et `source` par ligne. Les événements de statut portent aussi `data.previous_status`.

## En-têtes de livraison[​](#en-têtes-de-livraison "Lien direct vers En-têtes de livraison")

| En-tête          | Valeur                                                                                                                                   |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`   | `application/json`                                                                                                                       |
| `X-DZ-Timestamp` | Heure Unix en secondes au moment de la signature.                                                                                        |
| `X-DZ-Signature` | `t=<timestamp>,v1=<signature>`                                                                                                           |
| `X-DZ-Event`     | Le nom de l'événement, identique à `event` dans le corps.                                                                                |
| `X-DZ-Delivery`  | L'identifiant de livraison. Il reste le même entre les tentatives d'une livraison. La requête `webhook.verify` de la console n'en a pas. |
| `User-Agent`     | `DZBuild-Webhooks/1.0`                                                                                                                   |

## Signature[​](#signature "Lien direct vers Signature")

`X-DZ-Signature` contient deux parties séparées par une virgule :

* `t` est l'horodatage Unix, la même valeur que `X-DZ-Timestamp`.
* `v1` est le HMAC-SHA256 en hexadécimal minuscule de la chaîne `t` + `.` + le corps brut de la requête, avec le secret de signature de votre application comme clé.

Pour vérifier une requête :

1. Lisez les octets bruts du corps avant tout décodage JSON. Un corps décodé puis réencodé ne correspondra pas.
2. Calculez le HMAC-SHA256 avec votre secret de signature sur l'horodatage, un point, puis le corps brut.
3. Comparez le résultat à `v1` en temps constant.
4. Rejetez la requête si l'horodatage s'écarte de plus de 5 minutes de votre horloge. Cela empêche de rejouer une ancienne requête.

Votre secret de signature s'affiche dans la console développeur. Gardez-le uniquement sur votre serveur. Quand vous le régénérez, toutes les installations signent avec le nouveau secret dès la livraison suivante, sans période de chevauchement : déployez le nouveau secret sur votre serveur au même moment.

### Node.js[​](#nodejs "Lien direct vers 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 "Lien direct vers 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 "Lien direct vers 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
```

## Nouvelles tentatives et livraisons abandonnées[​](#nouvelles-tentatives-et-livraisons-abandonnées "Lien direct vers Nouvelles tentatives et livraisons abandonnées")

Une livraison réussit quand votre serveur répond avec un statut `2xx` en moins de 10 secondes. Tout autre statut, une redirection (`3xx`), un délai dépassé ou une erreur de connexion compte comme une tentative échouée. Les redirections ne sont pas suivies.

Chaque livraison a droit à 5 tentatives au maximum. Après une tentative échouée, la suivante attend au moins :

| Tentative échouée | Attente avant la tentative suivante                                             |
| ----------------- | ------------------------------------------------------------------------------- |
| 1                 | 60 secondes                                                                     |
| 2                 | 5 minutes                                                                       |
| 3                 | 30 minutes                                                                      |
| 4                 | 2 heures                                                                        |
| 5                 | Aucune. La livraison est marquée comme abandonnée et n'est plus jamais envoyée. |

Ces attentes sont des minimums, car l'envoi tourne une fois par minute. La livraison est « au moins une fois » : une requête peut arriver deux fois, par exemple après une panne réseau du côté de DZBuild. Écartez les doublons grâce à l'`id` de l'enveloppe.

Après 10 tentatives échouées d'affilée, toutes livraisons confondues, l'endpoint de cette installation est désactivé et ses livraisons en attente sont abandonnées. Corrigez votre serveur, puis cliquez sur « Vérifier le webhook » dans la console développeur pour réactiver la livraison. Les livraisons déjà abandonnées ne sont pas renvoyées.

Les livraisons en attente sont aussi abandonnées sans envoi quand DZBuild suspend votre application, quand le plan de la boutique passe sous le plan minimum de votre application, ou quand une application en mode test tourne sur une boutique qui n'appartient pas au développeur. `app.uninstalled` fait exception et part quand même.

## app.uninstalled[​](#appuninstalled "Lien direct vers app.uninstalled")

Quand un marchand désinstalle votre application, DZBuild révoque le jeton d'installation, abandonne toutes les livraisons en attente de cette installation et envoie un événement `app.uninstalled` si l'endpoint de l'installation est actif et vérifié :

```
{

  "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` est en UTC. L'avis suit le même calendrier de tentatives, et l'endpoint reste actif un jour après la désinstallation pour que les tentatives aboutissent. Si le marchand réinstalle votre application sur cette boutique avant la livraison de l'avis, l'avis en attente est abandonné pour ne pas annuler la nouvelle installation.

Après la désinstallation, les appels à l'API avec l'ancien jeton d'installation répondent `401`. Traitez les données de la boutique comme le décrivent les [règles de sécurité](https://dzbuild.dev/fr/fr/security.md).

## Pas de X-DZ-Token pour les applications[​](#pas-de-x-dz-token-pour-les-applications "Lien direct vers Pas de X-DZ-Token pour les applications")

Les webhooks créés par un marchand dans le tableau de bord DZBuild portent aussi un en-tête `X-DZ-Token`, une copie en clair du secret destinée aux outils no-code. Les livraisons aux applications ne le portent jamais : un seul secret de signature couvre toutes les boutiques qui installent votre application, et une copie dans un journal de requêtes permettrait à n'importe qui de forger des événements pour toutes. Vérifiez toujours `X-DZ-Signature`.
