Vérifier les signatures
Les webhooks API v1 (POST /v1/webhooks) et l'addon Webhooks 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
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 :
- Utilisez les bytes raw du body. Re-sérialiser le JSON change l'entrée de la signature.
- Comparez à temps constant. Un
==classique fuit du timing exploitable en bruteforce. - 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
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
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" |
| 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
| 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 |
| 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é
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,
408ou429, 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.