LidmeoDevelopers

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 :

  1. le contenu signé est webhook-id + . + webhook-timestamp + . + le corps brut, tel que reçu ;
  2. la clé est le secret sans son préfixe whsec_, décodé en base64 ;
  3. la signature est v1, suivi du HMAC-SHA256 du contenu, en base64 ;
  4. l'en-tête webhook-signature peut porter plusieurs signatures séparées par une espace (pendant un changement de secret) : une seule qui correspond suffit ;
  5. 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

verify.mjs
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

verify.py
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 False

Avec Flask : verify_lidmeo_webhook(secret, request.headers, request.get_data()).

PHP

verify.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

verify.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 :

Secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
Corps{"test": 2432232314}
Signature attenduev1,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.

Sur cette page