Vérifier la signature
N’importe qui peut envoyer un POST à votre URL. La signature prouve que l’envoi vient de Vizalia et n’a pas été modifié : vérifiez-la sur chaque envoi, avant de lire le corps.
Les trois en-têtes
Section intitulée « Les trois en-têtes »| En-tête | Contenu |
|---|---|
webhook-id |
Identifiant de l'événement ( |
webhook-timestamp |
Horodatage Unix de l'envoi, en secondes. Il entre dans la signature ; refusez un envoi décalé de plus de 5 minutes. |
webhook-signature |
Signatures séparées par une espace, chacune au format |
Avec la bibliothèque officielle
Section intitulée « Avec la bibliothèque officielle »Le paquet standardwebhooks existe en Node.js, Python, Ruby, PHP, Go, Java et d’autres langages (liste). Passez-lui le secret de l’abonnement tel quel, whsec_ compris, le corps brut et les en-têtes.
// Réception des webhooks avec Express et la bibliothèque officielle :// npm install express standardwebhooksimport express from 'express';import { Webhook } from 'standardwebhooks';
const webhook = new Webhook(process.env.VIZALIA_WEBHOOK_SECRET); // whsec_…, tel quelconst app = express();
// Le corps brut, octet pour octet : la signature porte sur lui, pas sur un JSON relu.app.post('/webhooks/vizalia', express.raw({ type: 'application/json' }), (req, res) => { let evenement; try { evenement = webhook.verify(req.body, req.headers); } catch { return res.status(400).send('Signature invalide'); }
// Répondre vite (moins de 15 secondes), traiter ensuite. res.sendStatus(204); traiter(evenement, req.headers['webhook-id']);});
function traiter(evenement, idEnvoi) { // idEnvoi (= evenement.id) ne change pas d'une tentative à l'autre : gardez-le // pour ignorer un doublon. Les envois peuvent arriver dans le désordre. console.log(idEnvoi, evenement.type, evenement.session_id);}
app.listen(3000);"""Réception des webhooks avec Flask et la bibliothèque officielle.
pip install flask standardwebhooks"""import os
from flask import Flask, requestfrom standardwebhooks import Webhook
webhook = Webhook(os.environ["VIZALIA_WEBHOOK_SECRET"]) # whsec_…, tel quelapp = Flask(__name__)
@app.post("/webhooks/vizalia")def recevoir(): try: # Le corps brut, octet pour octet : la signature porte sur lui. evenement = webhook.verify(request.get_data(), dict(request.headers)) except Exception: return "Signature invalide", 400
# Répondez vite (moins de 15 secondes) : en production, confiez le # traitement à une file de tâches. webhook-id ne change pas d'une # tentative à l'autre : gardez-le pour ignorer un doublon. print(request.headers["webhook-id"], evenement["type"], evenement["session_id"]) return "", 204À la main
Section intitulée « À la main »Sans bibliothèque, la vérification tient en trois étapes : refuser un horodatage décalé de plus de 5 minutes, calculer le HMAC-SHA256 de id.horodatage.corps avec le secret décodé, puis comparer en temps constant avec chacune des signatures reçues.
// Vérification sans bibliothèque (Node.js), pour comprendre ou pour un// langage sans bibliothèque Standard Webhooks.import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifier(secret, corpsBrut, entetesRecus, tolerance = 300) { // Les noms d'en-têtes ne tiennent pas compte de la casse. const entetes = Object.fromEntries(Object.entries(entetesRecus).map(([nom, valeur]) => [nom.toLowerCase(), valeur])); const id = entetes['webhook-id']; const horodatage = entetes['webhook-timestamp']; const signatures = entetes['webhook-signature']; if (!id || !horodatage || !signatures) throw new Error('En-têtes de signature absents');
// 1. Refuser un envoi décalé de plus de 5 minutes (rejeu). if (Math.abs(Date.now() / 1000 - Number(horodatage)) > tolerance) throw new Error('Horodatage hors délai');
// 2. HMAC-SHA256 de « id.horodatage.corps », avec le secret décodé (la partie base64 après whsec_). const cle = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const attendue = createHmac('sha256', cle).update(`${id}.${horodatage}.${corpsBrut}`).digest();
// 3. Authentique si l'une des signatures correspond (il y en a deux pendant un renouvellement de secret). const valide = signatures.split(' ').some((signature) => { const [version, valeur] = signature.split(','); const recue = Buffer.from(valeur ?? '', 'base64'); return version === 'v1' && recue.length === attendue.length && timingSafeEqual(recue, attendue); }); if (!valide) throw new Error('Signature invalide');
return JSON.parse(corpsBrut);}<?php// Vérification sans bibliothèque (PHP 7.4 ou plus).// $corpsBrut = file_get_contents('php://input'), jamais un JSON relu puis réencodé ;// $entetes = getallheaders().
function verifierWebhook(string $secret, string $corpsBrut, array $entetes, int $tolerance = 300): array{ // Les noms d'en-têtes ne tiennent pas compte de la casse (getallheaders() rend « Webhook-Id »). $entetes = array_change_key_case($entetes, CASE_LOWER); $id = $entetes['webhook-id'] ?? null; $horodatage = $entetes['webhook-timestamp'] ?? null; $signatures = $entetes['webhook-signature'] ?? null; if (!$id || !$horodatage || !$signatures) { throw new RuntimeException('En-têtes de signature absents'); }
// 1. Refuser un envoi décalé de plus de 5 minutes (rejeu). if (abs(time() - (int) $horodatage) > $tolerance) { throw new RuntimeException('Horodatage hors délai'); }
// 2. HMAC-SHA256 de « id.horodatage.corps », avec le secret décodé (la partie base64 après whsec_). $cle = base64_decode(preg_replace('/^whsec_/', '', $secret)); $attendue = base64_encode(hash_hmac('sha256', "$id.$horodatage.$corpsBrut", $cle, true));
// 3. Authentique si l'une des signatures correspond (deux pendant un renouvellement de secret). foreach (explode(' ', $signatures) as $signature) { [$version, $valeur] = array_pad(explode(',', $signature, 2), 2, ''); if ($version === 'v1' && hash_equals($attendue, $valeur)) { return json_decode($corpsBrut, true, 512, JSON_THROW_ON_ERROR); } } throw new RuntimeException('Signature invalide');}# Vérification sans bibliothèque (Ruby 2.7 ou plus).# Avec Rails : corps_brut = request.raw_post ; entetes = { "webhook-id" => request.headers["webhook-id"], … }require "base64"require "json"require "openssl"
def verifier_webhook(secret, corps_brut, entetes, tolerance: 300) # Les noms d'en-têtes ne tiennent pas compte de la casse. entetes = entetes.to_h.transform_keys { |cle| cle.to_s.downcase } id = entetes["webhook-id"] horodatage = entetes["webhook-timestamp"] signatures = entetes["webhook-signature"] raise "En-têtes de signature absents" unless id && horodatage && signatures
# 1. Refuser un envoi décalé de plus de 5 minutes (rejeu). raise "Horodatage hors délai" if (Time.now.to_i - horodatage.to_i).abs > tolerance
# 2. HMAC-SHA256 de « id.horodatage.corps », avec le secret décodé (la partie base64 après whsec_). cle = Base64.strict_decode64(secret.delete_prefix("whsec_")) attendue = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", cle, "#{id}.#{horodatage}.#{corps_brut}"))
# 3. Authentique si l'une des signatures correspond (deux pendant un renouvellement de secret). valide = signatures.split(" ").any? do |signature| version, valeur = signature.split(",", 2) version == "v1" && valeur.to_s.bytesize == attendue.bytesize && OpenSSL.fixed_length_secure_compare(valeur, attendue) end raise "Signature invalide" unless valide
JSON.parse(corps_brut)endCes trois fonctions sont testées contre la bibliothèque officielle : même signature valide, même refus d’un corps modifié, d’un autre secret ou d’un envoi vieux de plus de 5 minutes, et deux signatures acceptées pendant un renouvellement de secret.
Ignorer les doublons
Section intitulée « Ignorer les doublons »Les envois arrivent au moins une fois, sans ordre garanti. webhook-id (égal au champ id de l’événement) reste le même d’une tentative à l’autre : enregistrez-le en traitant l’événement, et ignorez un webhook-id déjà vu. En cas de doute sur l’état d’une session, relisez-la avec GET /sessions/{session_id}.
Tester sa vérification
Section intitulée « Tester sa vérification »POST /webhook_endpoints/{endpoint_id}/test envoie tout de suite un événement fictif signé à votre URL, et renvoie la réponse de votre serveur. Un 2xx dit que la vérification passe ; un 400 de votre part, qu’elle échoue. Voir Mode test.
