Passer au contenu principal

Comment vérifier la signature HMAC d'un webhook

Reconstruire la signature d'un événement Eagr en HMAC-SHA256, les trois en-têtes envoyés et les six erreurs les plus fréquentes.

Écrit par Thibault Beauchesne

Ton endpoint reçoit bien les requêtes, mais tu ne sais pas si elles viennent d'Eagr.

La signature répond à cette question. Elle est calculée à chaque envoi, avec le secret que tu as copié à la création du webhook, et elle tient en une ligne de code.

Le principe est simple. Les erreurs, elles, sont toujours les mêmes six.

Ce qu'il te faut avant de commencer

  • Le secret complet, préfixe whsec_ compris. Il n'est affiché qu'une fois, à la création.

  • L'accès au corps brut de la requête, avant tout parsage par ton framework.

  • Une fonction HMAC-SHA256 et une comparaison à temps constant.

Les trois en-têtes envoyés

Chaque requête POST d'Eagr porte trois en-têtes.

En-tête

Contenu

X-Eagr-Signature

La signature, en hexadécimal, sans préfixe ni schéma

X-Eagr-Timestamp

L'horodatage de l'envoi, en secondes depuis l'époque Unix

X-Eagr-Event-Id

L'identifiant de l'événement, stable entre les tentatives

Le type de contenu est toujours application/json.

1. Reconstruis la chaîne à signer

Texte d'aide affiché dans la fenêtre de création du webhook.

Le produit décrit la méthode mot pour mot dans la fenêtre de création : « Pour vérifier un événement : calculez le HMAC-SHA256 de l'horodatage, un point, puis le corps brut, avec ce secret complet comme clé (préfixe whsec_ inclus), et comparez-le à l'en-tête X-Eagr-Signature. »

Concrètement, la chaîne signée est :

<X-Eagr-Timestamp> + "." + <corps brut de la requête>

Un point littéral. Pas un deux-points, pas un espace, pas de saut de ligne.

2. Calcule le HMAC

Algorithme HMAC-SHA256. Clé : le secret complet, en UTF-8, préfixe whsec_ inclus. Sortie en hexadécimal minuscule.

En Node.js :

import { createHmac, timingSafeEqual } from "node:crypto";function verify(rawBody, timestamp, signature, secret) {
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

En Python :

import hmac, hashlibdef verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

3. Contrôle l'horodatage

Compare X-Eagr-Timestamp à l'heure de ton serveur et rejette l'écart trop grand. La tolérance recommandée est de 300 secondes.

Cette fenêtre est ce qui empêche quelqu'un de rejouer indéfiniment une requête interceptée.

4. Ignore les doublons

Sers-toi de X-Eagr-Event-Id. Il ne change pas d'une tentative à l'autre.

Eagr réessaie jusqu'à cinq fois quand ton serveur ne répond pas en 2xx, soit six tentatives au total en comptant l'envoi initial. Si ta première réponse s'est perdue mais que ton traitement a bien eu lieu, tu recevras le même événement plusieurs fois. Garde les identifiants déjà traités, et réponds 200 sans retraiter.

Que dois-je répondre à Eagr

Un code 2xx, en moins de 10 secondes. Accuse réception dès que la signature est validée, et fais ton traitement métier en tâche de fond.

Le code que tu renvoies décide de la suite :

  • 2xx, la livraison est réussie, Eagr n'insiste pas.

  • 408, 429 ou tout 5xx, Eagr réessaie.

  • Tout autre 4xx, Eagr considère l'erreur comme définitive et abandonne. Une signature refusée renvoyée en 401 ne sera donc jamais relivrée.

Ce dernier point compte quand tu déploies un correctif : les événements rejetés pendant la panne sont perdus, ils ne reviendront pas.

Pourquoi la signature change entre deux tentatives

Parce que l'horodatage change à chaque envoi, et que l'horodatage entre dans le calcul.

Ne calcule donc jamais ta signature attendue à partir d'un horodatage que tu as mémorisé au premier essai.

Et ne compte pas sur un corps identique d'une tentative à l'autre : il est reconstruit à chaque envoi à partir de l'appel tel qu'il est à cet instant. Si l'appel change entre deux relivraisons, le corps change avec lui. Le seul élément stable d'un événement est son identifiant, et c'est lui seul qui sert à dédupliquer.

Ma vérification échoue systématiquement

Ton framework a déjà parsé le corps
C'est la cause numéro un. Un JSON parsé puis resérialisé n'est plus le même octet pour octet : ordre des clés, espaces, échappements. Il te faut le corps brut, tel qu'il est arrivé. Sur Express, express.raw() sur la route du webhook. Sur Next.js, await request.text() avant tout json().

Tu as retiré le préfixe whsec_
Il fait partie de la clé. Le secret complet fait 70 caractères, préfixe inclus.

Tu compares en base64
La signature est en hexadécimal. Une comparaison base64 échouera toujours.

Ton séparateur n'est pas un point
L'horodatage, un point, le corps. Un deux-points ou un espace donne une signature différente.

L'horloge de ton serveur dérive
Une machine désynchronisée fait tomber tous les événements hors de la fenêtre de 300 secondes. Vérifie ton service de synchronisation horaire avant d'accuser Eagr.

Tu compares avec un opérateur d'égalité classique
Ça fonctionne, mais ça expose ton secret à une attaque temporelle. Utilise une comparaison à temps constant, et compare d'abord les longueurs.

Comment tester sans attendre un vrai appel

Le bouton Envoyer un test de la page Paramètres, puis Connexions, envoie un événement de type webhook.test, signé exactement comme un vrai événement, avec les mêmes trois en-têtes.

La réponse est Événement de test livré si ton endpoint accepte, ou Échec du test suivi du code HTTP. Un 401 ou un 403 signifie que ta vérification rejette le message : tu es au bon endroit pour déboguer.

La limite est de 5 tests par minute.

Ce que ça ne fait pas

La signature ne chiffre rien. Le corps circule en clair dans le TLS de ta connexion. Elle prouve l'origine et l'intégrité, pas la confidentialité.

Elle ne garantit pas l'unicité. Deux tentatives du même événement portent deux signatures valides différentes. La déduplication est ton travail, avec l'identifiant d'événement.

Le secret ne se change pas. Il n'existe pas de rotation : pour en changer, tu supprimes le webhook et tu en recrées un. Modifier l'URL, en revanche, conserve le secret.

Et Eagr ne vérifie pas ta réponse au-delà de son code HTTP. Un 200 renvoyé avant traitement est compté comme une livraison réussie, même si ton traitement échoue ensuite.

Avez-vous trouvé la réponse à votre question ?