Open in
Verifica le firme dei webhook
Ogni richiesta webhook e di strumento inviata da ThunderPhone è firmata con un timestamp. Verificala con la procedura o l
Ogni richiesta che inviamo al tuo server (consegne webhook, il webhook legacy a URL singolo e chiamate agli endpoint degli strumenti) contiene due intestazioni di firma. Entrambe usano HMAC-SHA256 con lo stesso segreto.
| Intestazione | Firma | Protezione dai replay |
|---|---|---|
X-ThunderPhone-Signature-V2 (consigliata) | Timestamp, metodo, percorso, query e corpo | Rifiuta le richieste più vecchie di 5 minuti |
X-ThunderPhone-Signature | Solo il corpo | Nessuna: una richiesta intercettata viene verificata per sempre |
Verifica X-ThunderPhone-Signature-V2 nelle nuove integrazioni e migra
quelle esistenti. X-ThunderPhone-Signature viene ancora inviato con ogni
richiesta, invariato, quindi i verificatori basati su di esso continuano a funzionare.
Annunceremo una data prima di smettere di inviarlo.
Firma V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etè l'ora Unix, in secondi, in cui abbiamo inviato la richiesta. Una consegna ritentata viene firmata di nuovo al momento dell'invio, con il propriot.v1è l'HMAC-SHA256 esadecimale della stringa firmata riportata sotto, calcolato con la tua chiave segreta.
Il valore è un elenco separato da virgole di coppie key=value. Ignora le chiavi che
non riconosci e accetta la richiesta se corrisponde un qualsiasi v1: potremmo aggiungere
una seconda firma in una modifica futura.
La stringa firmata
Quattro campi, ciascuno seguito da un carattere di nuova riga (\n, byte 0x0A) tranne
l'ultimo:
<t>\n<METHOD>\n<path and query>\n<body>| Campo | Valore |
|---|---|
t | Il valore t dell'header |
METHOD | Il metodo HTTP in maiuscolo: POST, GET, PUT, PATCH o DELETE |
| path and query | La destinazione della richiesta dalla riga della richiesta HTTP: il percorso, quindi ? e la stringa di query se presente. Nessuno schema, host o frammento. Usalo esattamente come è arrivato, senza decodificarlo né riordinare i parametri |
body | Il corpo grezzo della richiesta. Le chiamate agli strumenti GET e DELETE non hanno corpo, quindi la loro stringa firmata termina con la nuova riga dopo il percorso |
Per verificare:
- Prima di qualsiasi parsing, leggi l'header, il metodo, il percorso e la query grezzi, e il corpo grezzo.
- Estrai
te ogniv1dall'header. - Rifiuta la richiesta se
tè più di 300 secondi prima o dopo il tuo orologio. - Calcola l'HMAC-SHA256 esadecimale della stringa firmata con la tua chiave segreta.
- Confrontalo con
v1a tempo costante. Rispondi con401se non corrisponde nulla.
Esempio completo
Chiave segreta whsec_example_4c1f2a9b. Inviamo questa richiesta alle
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 stringa firmata è composta da queste quattro righe, senza una nuova riga dopo il corpo:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Il suo HMAC-SHA256 è
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Verifica la tua implementazione rispetto a questo valore oppure riproducilo con openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Una chiamata allo strumento GET a /tools/lookup?customer_id=cust_123 nello stesso
secondo ha un corpo vuoto. La sua stringa firmata è
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n e la sua firma
è 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Con gli SDK
Gli SDK Python e TypeScript includono una funzione di supporto
(thunderphone 0.2.0 o successivo, @thunderphone/sdk 0.2.0 o successivo). Questa
verifica la finestra di 5 minuti, confronta a tempo costante e accetta un elenco
di chiavi segrete per consentirti la rotazione senza tempi di inattività.
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
});Senza un 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)
);
});
}Configurazione del framework
Ogni esempio legge il percorso e la query non elaborati e il corpo non elaborato, quindi chiama l'helper dell'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) e raw_path e query_string di ASGI
(FastAPI) corrispondono esattamente al target così come ricevuto. get_full_path()
di Django e request.path di Flask decodificano prima il percorso, fornendo
la stessa stringa per i percorsi comuni. Se il percorso dell'endpoint contiene
caratteri con codifica percentuale o riservati, come %2F, ; o =, leggi invece il
target non elaborato fornito dal server (ad esempio request.META["RAW_URI"] su
Gunicorn).
Quale segreto?
Lo stesso segreto firma entrambe le intestazioni.
| 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.
Verifica delle chiamate agli strumenti
Quando l'agente invoca direttamente uno dei tuoi
strumenti funzione (lo strumento dispone di un
endpoint), la richiesta include queste intestazioni ThunderPhone oltre
a endpoint.headers configurato:
X-ThunderPhone-Call-ID— l'ID numerico della chiamata in corso.X-ThunderPhone-Signature-V2— la firma con timestamp precedente, generata con il tuo segreto webhook a livello di organizzazione.X-ThunderPhone-Signature— la firma legacy relativa al solo corpo, con la stessa chiave.
Due aspetti differiscono dai webhook:
- Gli strumenti
GET/DELETEnon hanno un corpo. Gli argomenti vengono trasmessi come parametri di query. La firma V2 li include tramite il percorso e la query, mentre il campo del corpo è vuoto. Verifica usando la stringa di query non elaborata: l'analisi dei parametri e la creazione di una nuova stringa di query possono modificarne l'ordine o la codifica. L'intestazione legacy firma la stringa di byte vuota. - Le organizzazioni senza un webhook legacy configurato non dispongono di un segreto dell'organizzazione. In
tal caso, le chiamate agli strumenti includono solo
X-ThunderPhone-Call-IDe nessuna intestazione di firma. Configura il webhook legacy (PUT /v1/webhook) per ottenere un segreto di firma oppure autentica le chiamate agli strumenti con una tua intestazione tramiteendpoint.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)
...In Express, un parser del corpo lascia req.body come oggetto vuoto in una richiesta GET,
quindi passa Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
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.
Firma legacy: X-ThunderPhone-Signature
L'header originale è l'HMAC-SHA256 esadecimale del solo body non elaborato, con lo stesso segreto. Non contiene alcun timestamp, quindi chiunque acquisisca una richiesta firmata può riprodurla. Viene ancora inviato con ogni richiesta mentre le integrazioni passano a V2.
- Leggi il body della richiesta non elaborato — gli esatti byte che ti abbiamo inviato.
- Calcola
hmac_sha256(secret, body).hexdigest(). - Confronta in tempo costante con
X-ThunderPhone-Signature.
Firmiamo esattamente i byte che trasmettiamo. Quei byte corrispondono anche alla
serializzazione JSON canonica del payload — chiavi ordinate
alfabeticamente, separatori compatti (, e : senza spazi), UTF-8.
Se il tuo framework espone solo JSON analizzato, serializzalo nuovamente in modo 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 body non elaborato — è un passaggio in meno ed evita le anomalie di conversione ripetuta dei numeri JSON in alcuni linguaggi. Lo stesso vale per il campo body di 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)
endErrori comuni
Deriva dell'orologio
La finestra di 5 minuti viene misurata rispetto all'orologio del tuo server. Mantienilo sincronizzato con NTP; un orologio con uno scarto superiore a 5 minuti rifiuta ogni richiesta.
Un proxy riscrive il percorso
Il percorso firmato è quello richiesto da noi: il percorso dell'URL che hai configurato in ThunderPhone. Se un proxy inverso o un gateway API rimuove o aggiunge un prefisso prima che l'applicazione riceva la richiesta, ricostruisci il percorso che abbiamo inviato (ad esempio, reinserendo il prefisso) prima della verifica.
Nuova serializzazione con 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 compromette l'HMAC. Verifica il corpo non elaborato — oppure, se
devi serializzarlo di nuovo, 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.
Segreto errato per gli endpoint degli strumenti
Le chiamate dirette agli endpoint degli strumenti sono firmate con il segreto webhook
a livello di organizzazione (GET /v1/webhook) — non con un segreto specifico
dell'endpoint da /v1/developer/webhook-endpoints. Riutilizza lo stesso helper,
ma assicurati di fornirgli il segreto dell'organizzazione nelle route degli strumenti.
Stringhe di query negli strumenti GET/DELETE
V2 firma la stringa di query esattamente come viene inviata. L'header legacy firma il corpo vuoto e mai la stringa di query, quindi l'hash dell'URL per l'header legacy non corrisponderà mai.
Duplicati nella finestra temporale
Il timestamp blocca i replay più vecchi di 5 minuti, non una seconda copia
all'interno di quella finestra. Le consegne agli endpoint sono almeno una volta, quindi può
arrivare anche un nuovo tentativo dello stesso evento; elimina i duplicati in base a
event_id dell'involucro.
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.