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

Version de démonstration. Le format décrit est une proposition de conception, susceptible d’évoluer. Aucun webhook réel n’est émis tant qu’aucun fournisseur n’est configuré.

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.

Exemple fictif en Node.js. Le secret et les événements sont inventés. À adapter avant tout usage réel.
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.

Exemple fictif en Node.js, à enregistrer dans server.mjs à côté de la fonction précédente.
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

  1. Exportez un secret de test : export TELQIO_WEBHOOK_SECRET=secret_de_test_exemple, puis lancez node server.mjs.
  2. Dans un autre terminal, signez un corps d’essai avec openssl et envoyez-le avec curl.
  3. 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.
Exemple fictif : événement et secret inventés, envoyés à votre propre machine uniquement.
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ômeCause probableQue faire
La signature est toujours refuséeLe 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 bonneHorloge du serveur décalée : l’écart dépasse 300 secondes.Synchronisez l’horloge (NTP).
Exception sur timingSafeEqualLongueurs différentes, oubli du contrôle préalable.Comparez d’abord les longueurs, comme dans l’exemple.
Événements traités deux foisLa 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é.