ThunderPhone 2.0 est disponible.En libre-service, à partir de 2 ¢/min.Découvrir l’annonce

Operations

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êteSigneProtection contre les attaques par rejeu
X-ThunderPhone-Signature-V2 (recommandé)Horodatage, méthode, chemin, requête et corpsRejeter les requêtes datant de plus de 5 minutes
X-ThunderPhone-SignatureCorps uniquementAucune : 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t est 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 propre t.
  • v1 est 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>
ChampValeur
tLa valeur t de l'en-tête
METHODLa méthode HTTP en majuscules : POST, GET, PUT, PATCH ou DELETE
chemin et requêteLa 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
bodyLe 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 :

  1. Avant toute analyse, lisez l'en-tête, la méthode, le chemin brut et la requête, ainsi que le corps brut.
  2. Récupérez t et chaque v1 de l'en-tête.
  3. Rejetez la requête si t est à plus de 300 secondes avant ou après votre horloge.
  4. Calculez le HMAC-SHA256 hexadécimal de la chaîne signée avec votre secret.
  5. Comparez-le à v1 en temps constant. Répondez 401 si 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.

Python
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
)
Node.js
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

Python
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)
Node.js
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.

FastAPI
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}
Express
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);
  },
);
Django
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.

SourceSecret
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 uniquesecret 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 :

  1. Les outils GET / DELETE n'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.
  2. 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-ID et 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 via endpoint.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.

  1. Lisez le corps de requête brut — les octets exacts que nous vous avons envoyés.
  2. Calculez hmac_sha256(secret, body).hexdigest().
  3. 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.

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

Piè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