Open in
Vérifier les signatures des webhooks
Chaque requête webhook et d’outil envoyée par ThunderPhone est signée avec un horodatage. Vérifiez-la à l’aide de la recette ou de l’assistant SDK présenté ici et rejetez les requêtes rejouées datant de plus de cinq minutes.
Chaque requête que nous envoyons à votre serveur (livraisons de webhooks, webhook historique à URL unique et appels aux endpoints d’outils) contient deux en-têtes de signature. Les deux utilisent HMAC-SHA256 avec le même secret.
| En-tête | Signe | Protection contre les attaques par rejeu |
|---|---|---|
X-ThunderPhone-Signature-V2 (recommandé) | Horodatage, méthode, chemin, requête et corps | Rejeter les requêtes datant de plus de 5 minutes |
X-ThunderPhone-Signature | Corps uniquement | Aucune : une requête capturée est vérifiée indéfiniment |
Vérifiez X-ThunderPhone-Signature-V2 dans les nouvelles intégrations et migrez les intégrations existantes vers celui-ci. X-ThunderPhone-Signature est toujours envoyé avec chaque requête, sans modification, afin que les vérificateurs basés sur cet en-tête continuent de fonctionner. Nous annoncerons une date avant de cesser de l’envoyer.
Signature V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etest l'heure Unix, en secondes, à laquelle nous avons envoyé la requête. Une livraison réessayée est signée à nouveau lors de son envoi, avec son propret.v1est le HMAC-SHA256 hexadécimal de la chaîne signée ci-dessous, calculé avec votre secret.
La valeur est une liste de paires key=value séparées par des virgules. Ignorez les clés que vous ne reconnaissez pas et acceptez la requête si un v1 correspond : nous pourrions ajouter une seconde signature lors d'une future modification.
La chaîne signée
Quatre champs, chacun suivi d'un saut de ligne (\n, octet 0x0A), à l'exception du dernier :
<t>\n<METHOD>\n<path and query>\n<body>| Champ | Valeur |
|---|---|
t | La valeur t de l'en-tête |
METHOD | La méthode HTTP en majuscules : POST, GET, PUT, PATCH ou DELETE |
| chemin et requête | La cible de la requête de la ligne de requête HTTP : le chemin, puis ? et la chaîne de requête lorsqu'il y en a une. N'incluez ni schéma, ni hôte, ni fragment. Utilisez-la exactement telle qu'elle est arrivée, sans la décoder ni réordonner les paramètres |
body | Le corps brut de la requête. Les appels d'outil GET et DELETE n'ont pas de corps ; leur chaîne signée se termine donc par le saut de ligne après le chemin |
Pour vérifier :
- Avant toute analyse, lisez l'en-tête, la méthode, le chemin brut et la requête, ainsi que le corps brut.
- Récupérez
tet chaquev1de l'en-tête. - Rejetez la requête si
test à plus de 300 secondes avant ou après votre horloge. - Calculez le HMAC-SHA256 hexadécimal de la chaîne signée avec votre secret.
- Comparez-le à
v1en temps constant. Répondez401si aucune valeur ne correspond.
Exemple détaillé
Secret whsec_example_4c1f2a9b. Nous envoyons cette requête à
t = 1767225600 (2026-01-01 00:00:00 UTC) :
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
{"customer_id":"cust_123"}La chaîne signée correspond à ces quatre lignes, sans saut de ligne après le corps :
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Son HMAC-SHA256 est
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Vérifiez votre implémentation avec cette valeur, ou reproduisez-la avec openssl :
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Un appel d'outil GET vers /tools/lookup?customer_id=cust_123 à la même
seconde a un corps vide. Sa chaîne signée est
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n et sa signature
est 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Avec les SDK
Les SDK Python et TypeScript incluent un assistant
(thunderphone 0.2.0 ou version ultérieure, @thunderphone/sdk 0.2.0 ou version ultérieure). Il
vérifie la fenêtre de 5 minutes, compare en temps constant et accepte une liste
de secrets afin que vous puissiez effectuer une rotation sans interruption de service.
from thunderphone import verify_signature
ok = verify_signature(
header, # the X-ThunderPhone-Signature-V2 value
secret=SECRET, # or [NEW_SECRET, OLD_SECRET] while rotating
method=method, # "POST"
path=path, # "/tools/lookup?region=us", exactly as received
body=raw_body, # bytes, before any JSON parsing
tolerance=300, # optional; seconds
)import { verifySignature } from "@thunderphone/sdk";
const ok = verifySignature({
header, // the X-ThunderPhone-Signature-V2 value
secret: SECRET, // or [NEW_SECRET, OLD_SECRET] while rotating
method, // "POST"
path, // "/tools/lookup?region=us", exactly as received
body: rawBody, // Buffer or string, before any JSON parsing
toleranceSeconds: 300, // optional
});Sans SDK
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
"""path is the path plus ?query exactly as received; body is the raw body."""
timestamp, signatures = None, []
for item in (header or "").split(","):
key, _, value = item.strip().partition("=")
if key == "t" and value.isdigit() and len(value) <= 15:
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
let timestamp;
const signatures = [];
for (const item of (header || "").split(",")) {
const [key, value = ""] = item.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === undefined) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
.update(body)
.digest("hex"),
);
return signatures.some((sig) => {
const candidate = Buffer.from(sig);
return (
candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected)
);
});
}Câblage du framework
Chaque exemple lit le chemin et la requête bruts, ainsi que le corps brut, puis appelle l’assistant du SDK.
import json
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
app = FastAPI()
def raw_path_and_query(request: Request) -> str:
path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
query = request.scope.get("query_string", b"").decode("latin-1")
return f"{path}?{query}" if query else path
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
import { verifySignature } from "@thunderphone/sdk";
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 ok = verifySignature({
header: req.header("X-ThunderPhone-Signature-V2"),
secret: process.env.WEBHOOK_SECRET,
method: req.method,
path: req.originalUrl, // path and query as received
body: req.body,
});
if (!ok) 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 HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
@csrf_exempt
@require_POST
def hook(request):
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=request.get_full_path(),
body=request.body, # raw bytes
):
return HttpResponseForbidden("invalid signature")
event = json.loads(request.body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})req.originalUrl (Express), ainsi que raw_path et query_string d’ASGI
(FastAPI), correspondent exactement à la cible telle qu’elle a été reçue. La méthode
get_full_path() de Django et request.path de Flask décodent d’abord le chemin, ce qui
produit la même chaîne pour les chemins ordinaires. Si le chemin de votre point de terminaison contient
des caractères encodés en pourcentage ou réservés, tels que %2F, ; ou =, lisez plutôt la
cible brute fournie par votre serveur (par exemple request.META["RAW_URI"] avec
Gunicorn).
Quel secret ?
Le même secret signe les deux en-têtes.
| 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.
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 ces 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-V2— la signature horodatée ci-dessus, associée à votre secret de webhook au niveau de l'organisation.X-ThunderPhone-Signature— la signature héritée portant uniquement sur le corps, avec la même clé.
Deux éléments diffèrent des webhooks :
- Les outils
GET/DELETEn'ont pas de corps. Les arguments sont transmis sous forme de paramètres de requête. La signature V2 les couvre via le chemin et la chaîne de requête, et le champ du corps est vide. Vérifiez à l'aide de la chaîne de requête brute : analyser les paramètres et créer une nouvelle chaîne de requête peut modifier leur ordre ou leur encodage. L'en-tête hérité signe la chaîne d'octets vide. - Les organisations sans webhook hérité configuré n'ont pas de secret d'organisation. Dans
ce cas, les appels d'outils ne contiennent que
X-ThunderPhone-Call-IDet aucun en-tête de signature. Configurez le webhook hérité (PUT /v1/webhook) pour obtenir un secret de signature, ou authentifiez les appels d'outils avec votre propre en-tête viaendpoint.headers.
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=ORG_WEBHOOK_SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
args = json.loads(body) if body else dict(request.query_params)
...Dans Express, un analyseur de corps laisse req.body sous forme d'objet vide lors d'un GET,
alors transmettez Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
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.
Signature héritée : X-ThunderPhone-Signature
L’en-tête d’origine est le HMAC-SHA256 hexadécimal du seul corps brut, avec le même secret. Il ne contient aucun horodatage ; toute personne qui capture une requête signée peut donc la rejouer. Il est toujours envoyé avec chaque requête pendant que les intégrations migrent vers V2.
- Lisez le corps de requête brut — les octets exacts que nous vous avons envoyés.
- Calculez
hmac_sha256(secret, body).hexdigest(). - Comparez en temps constant avec
X-ThunderPhone-Signature.
Nous signons exactement les octets que nous transmettons. 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.
Si votre framework n’expose que le JSON analysé, re-sérialisez-le de manière canonique et
calculez le HMAC de celui-ci :
# 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 les particularités de conversion aller-retour des nombres JSON dans certains langages. Il en va de même pour le champ de corps de V2.
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)
endPièges courants
Décalage d'horloge
La fenêtre de 5 minutes est mesurée par rapport à l'horloge de votre serveur. Maintenez-la synchronisée avec NTP ; une horloge décalée de plus de 5 minutes rejette chaque requête.
Un proxy réécrit le chemin
Le chemin signé est celui que nous avons demandé : le chemin de l'URL que vous avez configurée dans ThunderPhone. Si un proxy inverse ou une passerelle API supprime ou ajoute un préfixe avant que votre application ne voie la requête, reconstruisez le chemin que nous avons envoyé (par exemple, remettez le préfixe) avant de vérifier.
Nouvelle sérialisation avec le formatage par défaut
Analyser le corps puis le sérialiser à nouveau 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 sérialiser à nouveau, reproduisez 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 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 le même assistant,
mais assurez-vous de lui fournir le secret de l'organisation sur les routes d'outils.
Chaînes de requête sur les outils GET/DELETE
V2 signe la chaîne de requête exactement telle qu'elle est envoyée. L'en-tête hérité signe le corps vide et jamais la chaîne de requête ; hacher l'URL pour l'en-tête hérité ne correspondra donc jamais.
Doublons dans la fenêtre
L'horodatage empêche les relectures datant de plus de 5 minutes, mais pas une seconde copie
dans cette fenêtre. Les livraisons aux points de terminaison sont effectuées au moins une fois ; une
nouvelle tentative du même événement peut donc également arriver. Dédupliquez selon le
event_id de l'enveloppe.
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.