Vérification de signature
Prouver qu'une livraison vient bien de Wajub et n'a pas été rejouée.
Votre URL de webhook est publique. Quiconque la trouve peut y poster un payment.succeeded bien
formé et vous regarder livrer une commande que personne n'a payée. La signature est ce qui distingue
une vraie livraison de cette imitation, et c'est la seule chose qui le fasse.
Deux en-têtes portent la preuve.
| En-tête | Ce que c'est |
|---|---|
X-Wajub-Signature | v1= suivi d'un HMAC-SHA256 en hexadécimal |
X-Wajub-Timestamp | Secondes Unix, fait partie de ce qui est signé |
Ce qui est signé
Pas le corps seul. L'horodatage et le corps, joints par un seul point.
signed = "{timestamp}.{raw_body}"
digest = hex(hmac_sha256(signed, endpoint_secret))
header = "v1=" + digestL'horodatage est inclus dans le HMAC exprès : une livraison interceptée ne peut pas être rejouée un jour plus tard, car la déplacer dans le temps casse la signature. Comparer l'horodatage à l'heure actuelle constitue la seconde moitié de cette protection, et les SDKs le font pour vous avec une fenêtre de 300 secondes.
Avec un SDK
Le secret appartient au client, pas à l'appel.
# What Wajub sends you
POST /webhooks/wajub HTTP/1.1
X-Wajub-Signature: v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Wajub-Timestamp: 1748083260
X-Wajub-Event: payment.succeeded
X-Wajub-Delivery-Id: whd_7Yh2MpL4tRb3nP8sZcXv
Content-Type: application/json
{"id":"evt_…","event":"payment.succeeded","data":{…}}Le secret n'est pas un quatrième argument
constructEvent prend le payload, la signature, l'horodatage, puis la tolérance en secondes.
Passer votre secret à cette quatrième position n'authentifie rien. En Python, PHP et Ruby, il lève
une erreur à chaque livraison (une TypeError, une NoMethodError en Ruby) : tous les webhooks
échouent. En Node.js avant la 2.0.0, il désactivait sans bruit la protection contre le rejeu ; à
partir de la 2.0.0, une tolérance qui n'est pas un nombre positif est ignorée et la fenêtre de 300
secondes reste en place. Go, Java et C# la refusent à la compilation. Le secret se passe au client
que vous construisez, une seule fois.
Go est la seule exception à la valeur par défaut : son quatrième argument n'en a pas, passez donc 0
et le SDK applique la même fenêtre de 300 secondes.
Sans SDK
Trois étapes, dans cet ordre : vérifier le préfixe, recalculer le digest, comparer en temps constant. Ensuite seulement, regardez l'horodatage.
# Reproduce a signature locally from a captured delivery
TIMESTAMP=1748083260
BODY=$(cat delivery.json)
printf '%s.%s' "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "$WAJUB_WEBHOOK_SECRET" -hexLes quatre erreurs classiques
Le corps a été parsé avant d'être hashé
C'est la cause de presque toutes les signatures qui refusent de se vérifier. Votre framework parse le JSON, vous le resérialisez pour le hasher, et les octets reviennent dans un ordre différent ou avec d'autres espaces. Le digest porte sur les octets exacts que nous avons envoyés.
| Framework | Quoi utiliser |
|---|---|
| Express | express.raw({ type: 'application/json' }) sur cette route uniquement |
| Flask | request.get_data(), jamais request.json |
| Laravel | $request->getContent() |
| Go | io.ReadAll(r.Body) avant tout décodeur |
| Route handler Next.js | await request.text() |
La comparaison a levé une exception au lieu de renvoyer false
crypto.timingSafeEqual de Node lève une RangeError quand les deux buffers n'ont pas la même
longueur, et une signature falsifiée a généralement une longueur différente. Sans vérification
préalable de la longueur, votre handler plante précisément sur la requête qu'il devait rejeter.
hash_equals et compare_digest gèrent ce cas eux-mêmes.
Le type d'événement a été lu dans le mauvais champ
Le champ s'appelle `event`
L'enveloppe porte event, pas type. Avant 2.0.0, le SDK Node typait WebhookEvent avec
type : l'erreur compile sans problème et échoue à l'exécution, votre switch ne tombe dans
aucun cas et rien n'est livré. Lisez event.event, ou routez sur l'en-tête X-Wajub-Event.
L'horloge a dérivé
La vérification tolère 300 secondes d'écart entre l'horodatage signé et l'horloge de votre serveur. Un conteneur dont l'horloge a dérivé au-delà rejette toutes les livraisons, même avec une signature valide. Si tout s'est mis à échouer d'un coup sans que rien n'ait changé dans votre code, vérifiez NTP avant de vérifier le secret.
Livraisons du cycle de vie Sync
Les événements d'onboarding et de connexion que reçoit une plateforme Sync, dont account.connected,
sont signés de la même façon, avec le même secret d'endpoint, sous leurs propres en-têtes.
| En-tête | Ce que c'est |
|---|---|
X-Sync-Signature-V1 | v1= suivi du HMAC-SHA256 en hexadécimal de {timestamp}.{raw_body} |
X-Sync-Timestamp | Secondes Unix, fait partie de ce qui est signé |
Vérifiez-les comme toute autre livraison, avec la même fenêtre de 300 secondes : passez
X-Sync-Signature-V1 et X-Sync-Timestamp à constructEvent à la place des en-têtes X-Wajub-,
ou à la fonction verify de Sans SDK. Refusez une livraison dont l'horodatage
sort de la fenêtre, même si sa signature correspond. constructEvent renvoie le corps tel quel, et
ces livraisons ont un corps à plat plutôt que l'enveloppe habituelle, décrit dans
le catalogue des événements.
Enregistrez ensuite chaque X-Idempotency-Key traité, et ignorez une livraison dont la clé a déjà
été vue.
X-Sync-Event: account.connected
X-Sync-Timestamp: 1748083260
X-Sync-Signature-V1: v1=5d41f2a8c3e07b9a1f64d2e8b0c7a95e3f18d6b4a2c9e07f1b3d5a8c6e2f4b90
X-Sync-Signature: 2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
X-Idempotency-Key: 9b74c9897bac770ffc029102a200c5de35d0f7b1a8e7c3b6d4f2a1e0c9b8a7f6`X-Sync-Signature` sera retiré
Ces livraisons portent encore X-Sync-Signature, le HMAC-SHA256 du seul corps brut, en
hexadécimal sans v1=. Il ne signe pas l'heure, donc une livraison interceptée se vérifie encore
des jours plus tard. Il est conservé pour les intégrations qui le vérifient aujourd'hui et sera
retiré plus tard : vérifiez X-Sync-Signature-V1 et X-Sync-Timestamp à la place, et cessez de
le lire.
Le secret
Chaque endpoint a le sien, qui commence par whsec_, affiché une seule fois à la création de
l'endpoint puis consultable dans Konsole. Le renouveler se fait en un seul appel.
curl -X POST https://api.wajub.com/webhooks/9d3f6a2e-4b1c-4e8a-b7d2-5c9e1f0a3b64/rotate-secret \
-H "Authorization: sk.xxxxxxxxxxxxxxxx…"Wajub génère le nouveau secret : vous ne pouvez donc pas le déployer à l'avance. Il prend effet aussitôt : chaque tentative envoyée ensuite est signée avec lui, y compris les nouvelles tentatives d'événements plus anciens, car une tentative est signée au moment de son envoi. Pour basculer sans perdre de livraison :
- Modifiez votre gestionnaire pour qu'il accepte une signature faite avec l'un ou l'autre de deux secrets, et déployez-le avec le secret actuel seul.
- Renouvelez le secret, et ajoutez le nouveau, affiché une seule fois, comme second secret.
- Quand plus rien n'a été refusé depuis un moment, retirez l'ancien.
Une livraison refusée pendant la bascule n'est pas perdue : elle est retentée, signée avec le nouveau secret.
Pages associées
- Démarrage rapideRecevez d'abord un événement signé sur votre machine.
- Nouvelles tentatives et échecsCe qui se passe quand vous répondez 400, et à quelle fréquence.
- Dépannage des webhooksLa signature ne se vérifie jamais, symptôme par symptôme.
- Bonnes pratiques de sécuritéLa place des secrets de webhook parmi vos autres identifiants.