Vérifier les signatures des webhooks
Chaque requête de webhook et d’outil envoyée par ThunderPhone est signée. Vérifiez la signature une fois à l’aide de la méthode ci-dessous, puis réutilisez la même vérification sur chaque endpoint que vous exécutez.
Chaque requête que nous envoyons à votre serveur — livraisons de webhooks et
appels de points de terminaison d’outils — contient une signature HMAC-SHA256 dans l’en-tête
X-ThunderPhone-Signature. Configurez la vérification correctement une seule fois, puis
utilisez le même helper dans chaque gestionnaire.
L’algorithme
- Lisez le corps de requête brut — les octets exacts que nous vous avons envoyés par POST.
- Calculez
hmac_sha256(secret, body).hexdigest(). - Comparez en temps constant avec
X-ThunderPhone-Signature. (Une comparaison naïve de chaînes divulgue des informations de temporisation.)
Nous signons exactement les octets que nous transmettons ; la vérification du corps brut
fonctionne donc toujours. Ces octets correspondent également à la sérialisation JSON canonique
de la charge utile — clés triées par ordre alphabétique, séparateurs compacts
(, et : sans espaces), UTF-8. Cela vous offre une seconde méthode totalement
équivalente lorsque votre framework n’expose que le JSON analysé :
resérialisez de manière canonique et calculez le HMAC sur ce résultat.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Préférez le corps brut — cela évite une étape et vous protège des particularités d’aller-retour des nombres JSON dans certains langages.
Quel secret ?
| Source | Secret |
|---|---|
Point de terminaison webhook (/v1/developer/webhook-endpoints) | secret par point de terminaison (48 caractères hexadécimaux), renvoyé une seule fois lors de la création |
| Webhook hérité à URL unique | secret par organisation renvoyé par GET /v1/webhook |
Appel de point de terminaison d’outil (appel direct à votre endpoint.url) | Le secret webhook au niveau de l’organisation (le même que pour le webhook hérité à URL unique) — pas un secret par point de terminaison |
Stockez le secret dans votre gestionnaire de secrets ou une variable d’environnement — ne le validez jamais dans votre dépôt.
Implémentations de référence
Les quatre vérifient le corps de requête brut :
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endConfiguration spécifique au framework
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);import json
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})Vérification des appels d'outils
Lorsque l'agent appelle directement l'un de vos
outils de fonction (l'outil possède un
endpoint), la requête contient deux en-têtes ThunderPhone en plus de
vos endpoint.headers configurés :
X-ThunderPhone-Call-ID— l'identifiant numérique de l'appel en cours.X-ThunderPhone-Signature— HMAC-SHA256, avec comme clé votre secret webhook au niveau de l'organisation, calculé sur les octets exacts du corps de la requête.
Le même helper verify() fonctionne sans modification, avec deux particularités :
- Les outils
GET/DELETEn'ont pas de corps. Les arguments sont transmis sous forme de paramètres de requête, et la signature est calculée sur la chaîne d'octets vide — doncverify(b"", sig, secret)(Python) ouverify(Buffer.alloc(0), sig, secret)(Node). Ne hachez pas la chaîne de requête. - Les organisations sans webhook historique configuré n'ont pas de secret d'organisation. Dans ce cas, les appels d'outils contiennent uniquement
X-ThunderPhone-Call-IDet aucun en-tête de signature. Configurez le webhook historique (PUT /v1/webhook) pour obtenir un secret de signature, ou authentifiez les appels d'outils avec votre propre en-tête viaendpoint.headers.
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...La distribution d'outils en mode webhook (outils sans endpoint, envoyés
à votre webhook d'organisation sous la forme telephony.tool / web.tool) est un
webhook signé classique — la procédure standard ci-dessus s'applique. Consultez
Outils de fonction pour les deux formats de requête.
Pièges courants
Nouvelle sérialisation avec le formatage par défaut
Analyser le corps puis le réexporter avec les paramètres par défaut
de votre bibliothèque JSON (espaces après , / :, clés dans l'ordre d'insertion) produit
des octets différents et invalide le HMAC. Vérifiez le corps brut — ou, si
vous devez le re-sérialiser, respectez exactement notre forme canonique : clés
triées, séparateurs compacts, UTF-8.
Le framework analyse automatiquement le JSON
Le middleware express.json() d'Express consomme le flux du corps
et vous perdez les octets bruts. Utilisez express.raw() spécifiquement sur la route
de webhook, ou mettez en mémoire tampon le corps brut dans un pré-middleware.
Même chose avec NestJS / Koa — consultez leur documentation sur le « corps brut ».
Comparaison non sécurisée contre les attaques temporelles
expected === signature en JS ou expected == signature en
Python sont des comparaisons dont la durée varie. Utilisez crypto.timingSafeEqual
ou hmac.compare_digest respectivement. La différence de performances
est nulle.
Mauvais secret pour les points de terminaison d'outils
Les appels directs aux points de terminaison d'outils sont signés avec le secret de webhook
au niveau de l'organisation (GET /v1/webhook) — et non avec un secret propre à chaque point de terminaison
provenant de /v1/developer/webhook-endpoints. Réutilisez la même fonction verify(),
mais assurez-vous de lui fournir le secret de l'organisation sur les routes d'outils.
Hachage de la chaîne de requête pour les outils GET/DELETE
Pour les méthodes d'outils sans corps, la signature couvre la chaîne d'octets vide, ce qui conserve une recette universelle : appliquez le HMAC au corps brut de la requête, quel qu'il soit. Hacher l'URL ou la chaîne de requête ne correspondra jamais.
Ne pas renvoyer 401 en cas de non-correspondance
Renvoyer 200 lorsque la vérification échoue fait du gestionnaire une cible de rejeu. Répondez toujours avec un code autre que 2xx si la vérification échoue.
Étapes suivantes
Sémantique de livraison, nouvelles tentatives, adresses IP sources.
Gérez plusieurs URL, effectuez la rotation des secrets.
Les deux chemins d'invocation d'outils et la structure de leurs requêtes.
Créez une intégration complète basée sur des outils de bout en bout.