Vérifier une signature
S'assurer qu'un envoi vient bien de Lidmeo, en Node, Python, PHP et Go.
Votre adresse de réception est publique : n'importe qui peut lui envoyer une requête. La signature prouve qu'un envoi vient de Lidmeo et qu'il n'a pas été modifié. Vérifiez-la avant tout traitement, et répondez 400 si elle ne va pas.
Le principe
Lidmeo suit Standard Webhooks :
- le contenu signé est
webhook-id+.+webhook-timestamp+.+ le corps brut, tel que reçu ; - la clé est le secret sans son préfixe
whsec_, décodé en base64 ; - la signature est
v1,suivi du HMAC-SHA256 du contenu, en base64 ; - l'en-tête
webhook-signaturepeut porter plusieurs signatures séparées par une espace (pendant un changement de secret) : une seule qui correspond suffit ; - refusez un
webhook-timestampà plus de 5 minutes de votre horloge : un envoi intercepté ne peut pas être rejoué plus tard.
Le corps brut
Vérifiez les octets reçus, avant tout décodage JSON. Un corps relu puis réécrit (espaces, ordre des champs) ne donne plus la même signature.
Avec les SDK
import { verifyWebhook, WebhookVerificationError } from "@lidmeo/sdk";
// rawBody : le corps tel que reçu (chaîne ou octets)
try {
const event = verifyWebhook(process.env.LIDMEO_WEBHOOK_SECRET!, rawBody, request.headers);
// event.type, event.data.object…
} catch (error) {
if (error instanceof WebhookVerificationError) {
// répondez 400, ne traitez rien
}
}Les bibliothèques officielles standardwebhooks (Node, Python, PHP, Go, Ruby, Java…) vérifient aussi les envois de Lidmeo telles quelles.
Sans bibliothèque
Chaque exemple ci-dessous est une fonction complète, qui rend vrai si l'envoi est authentique.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLidmeoWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !signatures || !/^\d+$/.test(timestamp ?? "")) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.split(" ").some((part) => {
const [version, value] = part.split(",");
if (version !== "v1" || !value) return false;
const got = Buffer.from(value, "base64");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}headers : les en-têtes aux noms en minuscules, comme req.headers de Node. Avec Express, gardez le corps brut : express.raw({ type: "application/json" }), puis req.body.toString("utf8").
Python
import base64
import hashlib
import hmac
import time
def verify_lidmeo_webhook(secret: str, headers, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures or not timestamp.isdecimal():
return False
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for part in signatures.split(" "):
version, _, value = part.partition(",")
if version == "v1" and hmac.compare_digest(value.encode(), expected):
return True
return FalseAvec Flask : verify_lidmeo_webhook(secret, request.headers, request.get_data()).
PHP
<?php
function lidmeo_verify_webhook(string $secret, array $headers, string $payload): bool
{
$headers = array_change_key_case($headers, CASE_LOWER);
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $signatures === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$payload", $key, true));
foreach (explode(' ', $signatures) as $part) {
[$version, $value] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return true;
}
}
return false;
}À l'appel : lidmeo_verify_webhook($secret, getallheaders(), file_get_contents('php://input')).
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func verifyLidmeoWebhook(secret string, header http.Header, body []byte) bool {
id := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatures := header.Get("webhook-signature")
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if id == "" || signatures == "" || err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-seconds)) > 300 {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "." + string(body)))
expected := mac.Sum(nil)
for _, part := range strings.Split(signatures, " ") {
version, value, ok := strings.Cut(part, ",")
if !ok || version != "v1" {
continue
}
got, err := base64.StdEncoding.DecodeString(value)
if err == nil && hmac.Equal(got, expected) {
return true
}
}
return false
}Dans votre gestionnaire : lisez le corps avec io.ReadAll(r.Body), puis verifyLidmeoWebhook(secret, r.Header, body).
Un jeu d'essai
Pour vérifier votre code sans attendre un envoi, le vecteur de test de Standard Webhooks :
| Secret | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp | 1614265330 |
| Corps | {"test": 2432232314} |
| Signature attendue | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
Son horodatage est ancien : désactivez le contrôle des 5 minutes le temps de cet essai. Ensuite, Envoyer un essai depuis Lidmeo vous envoie un événement d'essai (prospect.created), signé comme un vrai.