Verifica le firme dei webhook
Ogni webhook e richiesta di strumenti inviata da ThunderPhone è firmata. Verifica la firma una volta con la procedura qui riportata, quindi riutilizza lo stesso controllo su ogni endpoint che esegui.
Ogni richiesta che inviamo al tuo server — consegne webhook e invocazioni di endpoint degli strumenti — include una firma HMAC-SHA256 nell'header X-ThunderPhone-Signature. Configura correttamente la verifica una volta e usa lo stesso helper in ogni handler.
L'algoritmo
- Leggi il corpo raw della richiesta — gli esatti byte che ti abbiamo inviato con POST.
- Calcola
hmac_sha256(secret, body).hexdigest(). - Confronta in tempo costante con
X-ThunderPhone-Signature. (Un semplice confronto di stringhe espone informazioni sui tempi.)
Firmiamo esattamente i byte che trasmettiamo, quindi la verifica del corpo raw
funziona sempre. Quei byte corrispondono anche alla serializzazione JSON canonica
del payload — chiavi ordinate alfabeticamente, separatori compatti
(, e : senza spazi), UTF-8. Questo ti offre un secondo metodo del tutto
equivalente quando il tuo framework espone solo JSON analizzato:
serializzalo nuovamente in formato canonico e calcola l'HMAC su quello.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Preferisci il corpo raw — richiede un passaggio in meno ed evita le particolarità della conversione ripetuta dei numeri JSON in alcuni linguaggi.
Quale segreto?
| Origine | Segreto |
|---|---|
Endpoint webhook (/v1/developer/webhook-endpoints) | secret per endpoint (48 caratteri esadecimali) restituito una sola volta alla creazione |
| Webhook legacy con singolo URL | secret per organizzazione restituito con GET /v1/webhook |
Invocazione di endpoint degli strumenti (chiamata diretta al tuo endpoint.url) | Il segreto webhook a livello di organizzazione (lo stesso del webhook legacy con singolo URL) — non un segreto per endpoint |
Archivia il segreto nel tuo gestore di segreti o in una variabile d'ambiente — non inserirlo mai nel repository.
Implementazioni di riferimento
Tutte e quattro verificano il corpo raw della richiesta:
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)
endConfigurazione specifica del 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})Verifica delle chiamate agli strumenti
Quando l'agente richiama direttamente uno dei tuoi
strumenti funzione (lo strumento dispone di un
endpoint), la richiesta include due header ThunderPhone oltre ai
tuoi endpoint.headers configurati:
X-ThunderPhone-Call-ID— l'ID numerico della chiamata attiva.X-ThunderPhone-Signature— HMAC-SHA256, con chiave costituita dal tuo segreto webhook a livello di organizzazione, calcolato sui byte esatti del corpo della richiesta.
Lo stesso helper verify() funziona senza modifiche, con due particolarità:
- Gli strumenti
GET/DELETEnon hanno un corpo. Gli argomenti vengono passati come parametri di query e la firma viene calcolata sulla stringa di byte vuota — quindiverify(b"", sig, secret)(Python) oppureverify(Buffer.alloc(0), sig, secret)(Node). Non calcolare l'hash della stringa di query. - Le organizzazioni senza un webhook legacy configurato non hanno un segreto dell'organizzazione. In
questo caso le chiamate agli strumenti includono solo
X-ThunderPhone-Call-IDe nessun header della firma. Configura il webhook legacy (PUT /v1/webhook) per ottenere un segreto di firma, oppure autentica le chiamate agli strumenti con un tuo header tramiteendpoint.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)
...L'instradamento degli strumenti in modalità webhook (strumenti senza un endpoint, inviati
al webhook della tua organizzazione come telephony.tool / web.tool) è un normale
webhook firmato: si applica la procedura standard descritta sopra. Consulta
Strumenti funzione per entrambe le forme di richiesta.
Errori comuni
Ri-serializzazione con la formattazione predefinita
L'analisi del corpo e la sua nuova serializzazione con le
impostazioni predefinite della libreria JSON (spazi dopo , / :, chiavi
nell'ordine di inserimento) produce byte diversi e invalida l'HMAC. Verifica il corpo grezzo oppure, se devi
ri-serializzarlo, corrispondi esattamente alla nostra forma canonica: chiavi
ordinate, separatori compatti, UTF-8.
Il framework analizza automaticamente JSON
Il middleware express.json() di Express consuma il flusso del corpo
e perdi i byte grezzi. Usa express.raw() specificamente sulla route
webhook oppure memorizza il corpo grezzo in un pre-middleware.
Lo stesso vale per NestJS / Koa: consulta la loro documentazione sul "corpo grezzo".
Confronto non sicuro rispetto ai tempi
expected === signature in JS o expected == signature in
Python sono confronti con tempi variabili. Usa crypto.timingSafeEqual
o hmac.compare_digest rispettivamente. La differenza di prestazioni
è nulla.
Secret errato per gli endpoint degli strumenti
Le chiamate dirette agli endpoint degli strumenti sono firmate con il secret webhook
a livello di organizzazione (GET /v1/webhook) — non con un secret per endpoint
da /v1/developer/webhook-endpoints. Riutilizza la stessa funzione verify(),
ma assicurati di fornirle il secret dell'organizzazione nelle route degli strumenti.
Hash della stringa di query negli strumenti GET/DELETE
Per i metodi degli strumenti senza corpo, la firma copre la stringa di byte vuota, mantenendo un'unica procedura universale: applica HMAC al corpo grezzo della richiesta, qualunque esso sia. L'hash dell'URL o della stringa di query non corrisponderà mai.
Mancata restituzione di 401 in caso di mancata corrispondenza
Restituire 200 quando la verifica fallisce rende l'handler un bersaglio per replay. Rispondi sempre con un codice diverso da 2xx se la verifica fallisce.
Passaggi successivi
Semantica di consegna, tentativi, IP di origine.
Gestisci più URL, ruota i secret.
I due percorsi di invocazione degli strumenti e le relative forme di richiesta.
Crea un'integrazione completa basata su strumenti dall'inizio alla fine.