🛑 Important — Les signatures des webhooks API v1 ne sont pas encore vérifiables par les marchands
Les livraisons issues de POST /v1/webhooks portent un en-tête X-DZ-Signature, mais il n'est pas calculé à partir du secret propre au webhook que vous avez reçu à l'enregistrement — ce secret ne joue aucun rôle dans la signature aujourd'hui.
Tout contrôle HMAC que vous écrirez contre votre secret de webhook rejettera donc 100 % des livraisons API v1 authentiques. Traitez X-DZ-Signature comme une valeur opaque en attendant la signature par webhook.
Que faire à la place pour les livraisons API v1 :
Rendez l'URL indevinable — un long segment de chemin aléatoire, ou un token partagé en query string que vous contrôlez à l'arrivée.
N'acceptez que du POST en HTTPS, et vérifiez que
X-DZ-Timestampest dans les 5 minutes de votre horloge.Relisez l'enregistrement avant d'agir. Appelez
GET /v1/orders/{id}avec votre clé API et faites confiance à ça, pas au body poussé.Dédupliquez sur le
delivery_iddu body.
Le code du reste de cette page s'applique à l'addon Webhooks marchand (/dashboard/webhooks, plan Unlimited et au-delà), dont les signatures sont vérifiables avec votre secret propre à chaque endpoint.
La recette (addon Webhooks marchand)
L'addon envoie un en-tête à la Stripe, délimité par des virgules, et signe 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.
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', req.get('X-DZ-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, abortapp = 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 [email protected]('/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', request.headers.get('X-DZ-Event'), event)
return '', 200
Go (net/http)
package mainimport (
"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 — une tentative, puis abandon |
Timeout total | 10 secondes | Idem : abandonné, jamais re-tenté |
Redirections | Non suivies | Un |
Vérification TLS | Stricte | Les certificats auto-signés ou expirés échouent sans retry |
Méthode / body |
| — |
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 API v1 avec votre secret de webhook | Chaque livraison rejetée en « mauvaise signature » | C'est attendu — l'API v1 ne signe pas avec ce 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 | L'addon signe |
Lire | Erreurs de parsing / signature vide |
|
Dérive d'horloge serveur | Rejets « Timestamp out of window » | Lancez NTP, vérifiez |
Comparer avec | Vulnérabilité subtile aux timing-attacks | Utilisez |
|
| 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 |
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 est re-POSTé toutes les 60 secondes, indéfiniment. Les doublons issus de ce chemin sont la routine, pas l'exception — la déduplication est obligatoire, pas défensive.
Un endpoint qui time out n'obtient aucun retry. La livraison est abandonnée après une tentative : un endpoint lent perd des événements plutôt que d'en recevoir en double. Acquittez vite et faites le travail en asynchrone.
Dédupliquez côté réception :
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.