# Vérifier les signatures

Une seule recette pour les deux systèmes de webhooks

Les webhooks API v1 (`POST /v1/webhooks`) et l'[addon Webhooks](https://dzbuild.com/fr/fr/api-docs/webhooks/overview.md) marchand (`/dashboard/webhooks`, plan Unlimited et au-delà) signent leurs livraisons de la même façon, chacun avec le secret du webhook ou de l'endpoint concerné. Pour un webhook API v1, c'est le `secret` renvoyé une seule fois par `POST /v1/webhooks`.

Un webhook enregistré via l'API avant l'arrivée de la signature par webhook garde l'ancienne signature : une valeur hex nue sans partie `t=`, qu'aucun secret en votre possession ne permet de recalculer. Supprimez ce webhook et enregistrez-le de nouveau, puis vérifiez avec le nouveau secret.

## La recette[​](#la-recette "Lien direct vers La recette")

Les deux systèmes envoient un en-tête à la Stripe, délimité par des virgules, et signent le **body brut**, pas un hash de celui-ci :

```
X-DZ-Signature: t=<unix seconds>,v1=<hex hmac-sha256>



expected = hex( hmac_sha256( WEBHOOK_SECRET, t + "." + raw_body ) )



if (!constant_time_equal(expected, v1)) reject 401

if (abs(now - t) > 300)                 reject 401   # fenêtre rejeu ±5 min
```

Trois règles pour rester safe :

1. **Utilisez les bytes raw du body.** Re-sérialiser le JSON change l'entrée de la signature.
2. **Comparez à temps constant.** Un `==` classique fuit du timing exploitable en bruteforce.
3. **Rejetez les timestamps périmés** (plus de 5 min d'écart avec l'horloge serveur). Lancez NTP.

L'ensemble complet des en-têtes d'une livraison de l'addon :

```
Content-Type:    application/json

User-Agent:      DZBuild-Webhooks/1.0

X-DZ-Timestamp:  <unix seconds>

X-DZ-Signature:  t=<unix seconds>,v1=<hex hmac-sha256>

X-DZ-Event:      order.confirmed

X-DZ-Delivery:   <numeric delivery id>

X-DZ-Token:      <your endpoint secret, in plain text>
```

`X-DZ-Token` est le jumeau pratique de la signature, destiné aux outils no-code (n8n, Make, Zapier) qui ne savent faire que de l'auth par en-tête : comparez-le à votre secret stocké avec un contrôle à temps constant. C'est un credential de type bearer dans un en-tête — ne l'utilisez jamais qu'en HTTPS, et préférez le HMAC quand vous écrivez du vrai code.

Une livraison API v1 arrive avec moins d'en-têtes :

```
Content-Type:     application/json

User-Agent:       dzbuild-webhook/1

X-DZ-Timestamp:   <unix seconds>

X-DZ-Signature:   t=<unix seconds>,v1=<hex hmac-sha256>

X-DZ-Delivery-Id: <numeric delivery id>
```

Elle ne porte ni `X-DZ-Event` ni `X-DZ-Token` : lisez le nom de l'événement dans le champ `event` du body vérifié. La signature ne couvre que le body : considérez `X-DZ-Delivery-Id` comme non authentifié.

## Code[​](#code "Lien direct vers Code")

**Node.js (Express)**

```
import crypto from 'node:crypto';

import express from 'express';



const app = express();

const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;



// "t=1717112657,v1=abc..." → { t: "1717112657", v1: "abc..." }

function parseSigHeader(raw) {

  const out = {};

  for (const part of String(raw || '').split(',')) {

    const i = part.indexOf('=');

    if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();

  }

  return out;

}



// IMPORTANT : capturez le body brut pour le HMAC, séparément du JSON parsé.

app.post('/webhooks/dzbuild',

  express.raw({ type: 'application/json' }),

  (req, res) => {

    const { t: ts, v1: sig } = parseSigHeader(req.get('X-DZ-Signature'));

    if (!ts || !sig) return res.status(401).end();



    if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {

      return res.status(401).end();   // timestamp périmé ou futur

    }



    const expected = crypto

      .createHmac('sha256', WEBHOOK_SECRET)

      .update(`${ts}.${req.body.toString('utf8')}`)   // body brut, pas un hash

      .digest('hex');



    // Contrôle de longueur d'abord — timingSafeEqual lève si les buffers diffèrent.

    if (expected.length !== sig.length ||

        !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {

      return res.status(401).end();

    }



    // Vérifié — vous pouvez maintenant parser et agir.

    const event = JSON.parse(req.body.toString('utf8'));

    console.log('verified', event.event, event);

    res.status(200).end();   // ack au plus vite

  });



app.listen(3000);
```

**PHP (raw)**

```
<?php

$secret = getenv('DZBUILD_WEBHOOK_SECRET');

$body   = file_get_contents('php://input');     // body brut

$header = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';



$parts = [];

foreach (explode(',', $header) as $piece) {

    $kv = explode('=', trim($piece), 2);

    if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }

}

$ts  = $parts['t']  ?? '';

$sig = $parts['v1'] ?? '';



if ($ts === '' || $sig === '') { http_response_code(401); exit; }

if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }



$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);



if (!hash_equals($expected, strtolower($sig))) {

    http_response_code(401);

    exit;

}



$event = json_decode($body, true);

// Traitez $event['event'], $event['data']

http_response_code(200);
```

Sous Laravel, utilisez un middleware de route ou un contrôleur qui lit `$request->getContent()` pour le body brut. Désactivez le CSRF sur la route webhook.

**Python (Flask)**

```
import hashlib, hmac, os, time

from flask import Flask, request, abort



app = Flask(__name__)

WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()



def parse_sig(raw):

    out = {}

    for part in (raw or '').split(','):

        k, _, v = part.partition('=')

        if v:

            out[k.strip()] = v.strip()

    return out



@app.post('/webhooks/dzbuild')

def receive():

    parts = parse_sig(request.headers.get('X-DZ-Signature'))

    ts, sig = parts.get('t'), parts.get('v1')

    if not ts or not sig: abort(401)

    if abs(int(time.time()) - int(ts)) > 300: abort(401)



    body = request.get_data()      # bytes bruts — N'UTILISEZ PAS request.json

    expected = hmac.new(WEBHOOK_SECRET,

                        ts.encode() + b'.' + body,

                        hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, sig.lower()): abort(401)



    event = request.get_json()

    print('verified', event['event'], event)

    return '', 200
```

**Go (net/http)**

```
package main



import (

    "crypto/hmac"

    "crypto/sha256"

    "encoding/hex"

    "io"

    "net/http"

    "os"

    "strconv"

    "strings"

    "time"

)



var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))



func parseSig(h string) (ts, v1 string) {

    for _, part := range strings.Split(h, ",") {

        kv := strings.SplitN(strings.TrimSpace(part), "=", 2)

        if len(kv) != 2 { continue }

        switch kv[0] {

        case "t":

            ts = kv[1]

        case "v1":

            v1 = kv[1]

        }

    }

    return

}



func receive(w http.ResponseWriter, r *http.Request) {

    body, err := io.ReadAll(r.Body)

    if err != nil { http.Error(w, "read", 400); return }



    ts, sig := parseSig(r.Header.Get("X-DZ-Signature"))

    if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }



    tsInt, err := strconv.ParseInt(ts, 10, 64)

    if err != nil { http.Error(w, "ts", 401); return }

    if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }



    mac := hmac.New(sha256.New, secret)

    mac.Write([]byte(ts + "." + string(body)))

    expected := hex.EncodeToString(mac.Sum(nil))



    if !hmac.Equal([]byte(expected), []byte(sig)) {

        http.Error(w, "bad sig", 401); return

    }

    w.WriteHeader(http.StatusOK)

}



func abs(x int64) int64 { if x < 0 { return -x }; return x }
```

## Contraintes côté récepteur[​](#contraintes-côté-récepteur "Lien direct vers Contraintes côté récepteur")

Elles s'appliquent aux livraisons **API v1** et sont la réponse habituelle à « mon endpoint n'est jamais appelé » :

| Contrainte           | Valeur                                                                                                  | Ce qui arrive si vous l'enfreignez                                                                                                                                                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Timeout de connexion | 5 secondes                                                                                              | Compté comme un échec de transport : re-tenté avec backoff, abandonné après 5 tentatives                                                                                                                                                                          |
| Timeout total        | 10 secondes                                                                                             | Idem : re-tenté avec backoff, abandonné après 5 tentatives                                                                                                                                                                                                        |
| Redirections         | Non suivies                                                                                             | Un `301`/`302` est un échec, et un 3xx n'est jamais re-tenté                                                                                                                                                                                                      |
| Vérification TLS     | Stricte                                                                                                 | Les certificats auto-signés ou expirés échouent à chaque tentative                                                                                                                                                                                                |
| Schéma               | `https://` uniquement                                                                                   | L'enregistrement répond `400 bad_request` "url must be https"                                                                                                                                                                                                     |
| Hôte                 | Un nom d'hôte, pas une adresse IP, sur le port 443 ou 80, qui ne pointe que vers des adresses publiques | L'enregistrement répond `400 bad_request`. L'adresse est revérifiée avant chaque tentative : un hôte qui pointe plus tard vers une adresse privée, ou qui ne se résout plus, fait échouer la tentative ; elle est re-tentée, puis la livraison part en file morte |
| Statut de réponse    | `2xx` = livré                                                                                           | 5xx, `408` et `429` sont re-tentés ; tout autre 4xx, et tout 3xx, envoie la livraison en file morte immédiatement                                                                                                                                                 |
| Méthode / body       | `POST` simple, body JSON                                                                                | —                                                                                                                                                                                                                                                                 |

Pointez le webhook sur l'URL **finale** (pas de redirection `www` → apex, pas de rebond HTTP → HTTPS) et servez un certificat reconnu publiquement.

## Erreurs classiques[​](#erreurs-classiques "Lien direct vers Erreurs classiques")

| Erreur                                                                        | Symptôme                                                                                         | Correctif                                                                                                                                       |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Vérifier une livraison d'un webhook enregistré avant la signature par webhook | Chaque livraison rejetée en « mauvaise signature », et l'en-tête n'a pas de partie `t=`          | Supprimez le webhook, enregistrez-le de nouveau et utilisez le nouveau `secret`. Voir l'encadré en haut de page                                 |
| Re-sérialiser le body JSON avant de signer                                    | La signature ne matche jamais                                                                    | Utilisez les bytes raw du body — voir les notes par framework dans [Enregistrement](https://dzbuild.com/fr/fr/api-docs/webhooks/registering.md) |
| Hacher le body avant le HMAC                                                  | La signature ne matche jamais                                                                    | DZBuild signe `t + "." + raw_body`, pas un digest du body                                                                                       |
| Lire `t` depuis `X-DZ-Timestamp` mais `v1` depuis un en-tête nu               | Erreurs de parsing / signature vide                                                              | `X-DZ-Signature` est délimité par des virgules : `t=…,v1=…`                                                                                     |
| Dérive d'horloge serveur                                                      | Rejets « Timestamp out of window »                                                               | Lancez NTP, vérifiez `timedatectl status` sous Linux                                                                                            |
| Comparer avec `==` au lieu d'un temps constant                                | Vulnérabilité subtile aux timing-attacks                                                         | Utilisez `crypto.timingSafeEqual` / `hmac.compare_digest` / `hash_equals`                                                                       |
| `timingSafeEqual` sans contrôle de longueur                                   | `RangeError` levée au lieu d'un 401                                                              | Comparez d'abord les longueurs, comme dans l'exemple Node                                                                                       |
| Logger le secret sur disque                                                   | Le secret finit dans vos fichiers de logs                                                        | Ne le loggez pas ; utilisez un secrets store ; régénérez-le en cas de fuite                                                                     |
| Répondre 200 immédiatement et traiter plus tard                               | Événements perdus quand votre worker crashe                                                      | Persistez d'abord dans votre propre file puis acquittez, OU faites le travail de façon synchrone et acquittez en dernier                        |
| Dédupliquer sur l'en-tête `X-DZ-Delivery-Id`                                  | Une livraison rejouée avec un en-tête modifié passe votre déduplication et est traitée deux fois | Dédupliquez sur le `delivery_id` du body vérifié                                                                                                |

## Idempotence de votre côté[​](#idempotence-de-votre-côté "Lien direct vers Idempotence de votre côté")

Le même `delivery_id` peut arriver plusieurs fois. En API v1, cela prend une forme bien précise :

* **Un endpoint qui répond 5xx, `408` ou `429`, ou qui time out, est re-tenté jusqu'à quatre fois sur environ 2 h 30.** Les doublons sont la routine : une requête expirée chez nous a peut-être déjà été traitée chez vous, donc la déduplication est obligatoire.
* **Après le 5e échec, la livraison est abandonnée.** Un endpoint lent peut encore perdre des événements : acquittez vite et faites le travail en asynchrone.

Dédupliquez côté réception, sur le `delivery_id` du body vérifié (jamais sur l'en-tête `X-DZ-Delivery-Id`, qui n'est pas signé) :

```
INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);

-- Attrapez la violation UNIQUE sur delivery_id → déjà traité, répondez 200 quand même
```

Ce pattern fait que même si notre retry vous re-sollicite, vous faites le travail une seule fois et acquittez vite.
