Vérifier un webhook signé
Ce guide explique comment authentifier un événement reçu par webhook : lecture de l’en-tête, calcul de la signature, comparaison à temps constant, contrôle de la tolérance et déduplication. Exemple en Node.js, fictif.
Mis à jour le
Le principe
Chaque événement arrive avec un en-tête Telqio-Signature de la forme t=<horodatage unix>,v1=<signature hexadécimale>. La signature est un HMAC-SHA256 du texte « horodatage, point, corps brut », calculé avec le secret du point de terminaison.
Trois contrôles sont nécessaires : la signature correspond, l’horodatage est dans une tolérance de 300 secondes, et l’identifiant d’événement id n’a pas déjà été traité.
Avant de commencer
- Node.js avec les modules natifs. Rien d’autre à installer.
- Le secret du point de terminaison, lu dans une variable d’environnement. Ne l’écrivez jamais dans le code ni côté navigateur.
- Un accès au corps brut de la requête, avant tout passage par un analyseur JSON. Un corps reformaté change la signature.
La fonction de vérification
La comparaison utilise timingSafeEqual pour qu’un attaquant ne puisse pas deviner la signature octet par octet en mesurant le temps de réponse. Les longueurs sont contrôlées avant, car timingSafeEqual lève une erreur si elles diffèrent.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifySignature(rawBody, header, secret, nowMs = Date.now()) {
if (typeof header !== 'string') return false
const parts = {}
for (const item of header.split(',')) {
const index = item.indexOf('=')
if (index > 0) parts[item.slice(0, index).trim()] = item.slice(index + 1).trim()
}
const timestamp = Number(parts.t)
if (!Number.isInteger(timestamp) || !parts.v1) return false
const ageSeconds = Math.abs(Math.floor(nowMs / 1000) - timestamp)
if (ageSeconds > TOLERANCE_SECONDS) return false
const signedPayload = Buffer.concat([Buffer.from(timestamp + '.'), rawBody])
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest()
const received = Buffer.from(parts.v1, 'hex')
return expected.length === received.length && crypto.timingSafeEqual(expected, received)
}Le point de terminaison
Le serveur ci-dessous lit le corps brut, vérifie la signature, écarte les doublons, puis répond rapidement. Le Set en mémoire ne sert qu’à l’exemple : en pratique, conservez les identifiants traités dans un stockage partagé et durable.
import http from 'node:http'
import { verifySignature } from './verify.mjs'
const SECRET = process.env.TELQIO_WEBHOOK_SECRET
const seen = new Set()
http
.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhook') {
res.writeHead(404).end()
return
}
const chunks = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
if (!verifySignature(rawBody, req.headers['telqio-signature'], SECRET)) {
res.writeHead(400).end('invalid signature')
return
}
const event = JSON.parse(rawBody.toString('utf8'))
if (seen.has(event.id)) {
res.writeHead(200).end('duplicate')
return
}
seen.add(event.id)
console.log('événement reçu :', event.type, event.id)
res.writeHead(200).end('ok')
})
})
.listen(3000)Tester en local
- Exportez un secret de test : export TELQIO_WEBHOOK_SECRET=secret_de_test_exemple, puis lancez node server.mjs.
- Dans un autre terminal, signez un corps d’essai avec openssl et envoyez-le avec curl.
- Relancez la même commande : la réponse passe de ok à duplicate. Modifiez un caractère du corps sans recalculer la signature : la réponse devient invalid signature.
BODY='{"id":"evt_EXEMPLE_001","type":"call.completed","created":1794096000}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "secret_de_test_exemple" -hex | sed 's/^.* //')
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "Telqio-Signature: t=$T,v1=$SIG" \
-d "$BODY"Événements possibles
- call.ringing, call.connected, call.completed, call.failed
- message.sent, message.delivered, message.undelivered
- number.activated
Erreurs fréquentes
| Symptôme | Cause probable | Que faire |
|---|---|---|
| La signature est toujours refusée | Le corps a été relu puis resérialisé par un analyseur JSON. | Vérifiez sur les octets reçus, avant tout parsing. |
| Refus alors que la signature est bonne | Horloge du serveur décalée : l’écart dépasse 300 secondes. | Synchronisez l’horloge (NTP). |
| Exception sur timingSafeEqual | Longueurs différentes, oubli du contrôle préalable. | Comparez d’abord les longueurs, comme dans l’exemple. |
| Événements traités deux fois | La livraison est rejouée, et l’id n’est pas dédupliqué. | Mémorisez les id traités et répondez 200 aux doublons. |
| Secret exposé | Secret dans le dépôt, un journal ou le navigateur. | Remplacez-le et lisez-le uniquement côté serveur. |
Ce que ce guide ne fait pas
- Il ne prouve pas la sécurité de votre application : ce code est un point de départ à relire et à tester.
- Il ne fixe pas la politique de nouvelle tentative de livraison, qui sera bornée et visible dans la console.
- Il ne garantit pas l’ordre des événements : un message.delivered peut arriver avant d’autres. Appuyez-vous sur les identifiants et les horodatages.
- Aucun webhook réel n’existe tant qu’aucun fournisseur n’est configuré.
Questions fréquentes
Pourquoi utiliser le corps brut ?
La signature couvre les octets exacts envoyés. Un espace ou un ordre de clés modifié par un analyseur JSON suffit à produire une signature différente.
Que répondre pour un événement déjà traité ?
Un code 200. L’émetteur considère alors la livraison réussie et cesse de la rejouer, sans effet de bord de votre côté.