Open in
Webhookhandtekeningen verifiëren
Elke webhook- en toolaanvraag die ThunderPhone verstuurt, wordt ondertekend met een tijdstempel. Verifieer deze met het recept of de SDK-helper hier en wijs herhaalde aanvragen ouder dan vijf minuten af.
Elk verzoek dat we naar je server sturen (webhookleveringen, de verouderde webhook met één URL en aanroepen naar tool-endpoints) bevat twee handtekeningheaders. Beide gebruiken HMAC-SHA256 met hetzelfde geheim.
| Header | Ondertekent | Bescherming tegen replay-aanvallen |
|---|---|---|
X-ThunderPhone-Signature-V2 (aanbevolen) | Tijdstempel, methode, pad, query en body | Verwerp verzoeken die ouder zijn dan 5 minuten |
X-ThunderPhone-Signature | Alleen body | Geen: een onderschept verzoek blijft altijd verifieerbaar |
Verifieer X-ThunderPhone-Signature-V2 in nieuwe integraties en migreer
bestaande integraties ernaartoe. X-ThunderPhone-Signature wordt nog steeds
ongewijzigd met elk verzoek verzonden, zodat verificateurs die hierop zijn
gebaseerd blijven werken. We kondigen een datum aan voordat we stoppen met het
verzenden ervan.
Handtekening V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etis de Unix-tijd, in seconden, waarop we het verzoek hebben verzonden. Een opnieuw geprobeerd verzoek wordt opnieuw ondertekend wanneer het wordt verzonden, met een eigent.v1is de hexadecimale HMAC-SHA256 van de onderstaande ondertekende tekenreeks, met jouw geheim als sleutel.
De waarde is een door komma's gescheiden lijst met key=value-paren. Negeer sleutels die je
niet herkent en accepteer het verzoek als een v1 overeenkomt: we kunnen bij een toekomstige
wijziging een tweede handtekening toevoegen.
De ondertekende tekenreeks
Vier velden, elk gevolgd door één nieuwe regel (\n, byte 0x0A), behalve het
laatste:
<t>\n<METHOD>\n<path and query>\n<body>| Veld | Waarde |
|---|---|
t | De t-waarde uit de header |
METHOD | De HTTP-methode in hoofdletters: POST, GET, PUT, PATCH of DELETE |
| path and query | Het verzoekdoel uit de HTTP-verzoekregel: het pad, gevolgd door ? en de querytekenreeks als die aanwezig is. Geen schema, host of fragment. Gebruik dit exact zoals het is ontvangen, zonder het te decoderen of parameters te herschikken |
body | De onbewerkte aanvraagbody. Toolaanroepen met GET en DELETE hebben geen body, dus hun ondertekende tekenreeks eindigt met de nieuwe regel na het pad |
Verifiëren:
- Lees vóór enige parsing de header, de methode, het onbewerkte pad en de query, en de onbewerkte body.
- Neem
ten elkev1uit de header. - Wijs het verzoek af als
tmeer dan 300 seconden voor of na jouw klok ligt. - Bereken de hexadecimale HMAC-SHA256 van de ondertekende tekenreeks met jouw geheim.
- Vergelijk deze in constante tijd met
v1. Antwoord met401als niets overeenkomt.
Uitgewerkt voorbeeld
Geheim whsec_example_4c1f2a9b. We verzenden dit verzoek op
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"}De ondertekende tekenreeks bestaat uit deze vier regels, zonder nieuwe regel na de body:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}De HMAC-SHA256 ervan is
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Controleer je implementatie hiermee, of reproduceer deze met openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Een GET-toolaanroep naar /tools/lookup?customer_id=cust_123 in dezelfde
seconde heeft een lege body. De ondertekende tekenreeks is
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n en de handtekening
is 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Met de SDK's
De Python- en TypeScript-SDK's bevatten een helper
(thunderphone 0.2.0 of hoger, @thunderphone/sdk 0.2.0 of hoger). Deze
controleert het venster van 5 minuten, vergelijkt in constante tijd en accepteert een lijst
met geheimen zodat je zonder downtime kunt roteren.
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
});Zonder een 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)
);
});
}Framework-integratie
Elk voorbeeld leest het onbewerkte pad en de query en de onbewerkte body, en roept vervolgens de SDK-helper aan.
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) en de ASGI-waarden raw_path en query_string
(FastAPI) zijn precies het aanvraagdoel zoals ontvangen. Django's
get_full_path() en Flask's request.path decoderen het pad eerst, wat
voor gewone paden dezelfde tekenreeks oplevert. Als het endpointpad
procentgecodeerde of gereserveerde tekens bevat, zoals %2F, ; of =, lees
dan in plaats daarvan het onbewerkte doel dat je server beschikbaar stelt
(bijvoorbeeld request.META["RAW_URI"] op Gunicorn).
Welk geheim?
Hetzelfde geheim ondertekent beide headers.
| Bron | Geheim |
|---|---|
Webhookeindpunt (/v1/developer/webhook-endpoints) | secret per eindpunt (48 hexadecimale tekens), eenmalig teruggegeven bij het aanmaken |
| Verouderde webhook met één URL | secret per organisatie, teruggegeven via GET /v1/webhook |
Aanroep van een tool-eindpunt (rechtstreekse aanroep naar je endpoint.url) | Het webhookgeheim op organisatieniveau (hetzelfde als voor de verouderde webhook met één URL) — geen geheim per eindpunt |
Sla het geheim op in je geheimenbeheerder of omgevingsvariabele — commit het nooit.
Toolaanroepen verifiëren
Wanneer de agent een van je
functietools rechtstreeks aanroept (de tool heeft een
endpoint), bevat het verzoek deze ThunderPhone-headers naast je
geconfigureerde endpoint.headers:
X-ThunderPhone-Call-ID— de numerieke id van de actieve oproep.X-ThunderPhone-Signature-V2— de tijdgestempelde handtekening hierboven, ondertekend met je webhookgeheim op organisatieniveau.X-ThunderPhone-Signature— de verouderde handtekening die alleen de body omvat, met dezelfde sleutel.
Twee dingen verschillen van webhooks:
GET- /DELETE-tools hebben geen body. Argumenten worden als queryparameters verzonden. De V2-handtekening omvat deze via het pad en de query, en het bodyveld is leeg. Verifieer met de onbewerkte queryreeks: het parsen van de parameters en het opbouwen van een nieuwe queryreeks kan de volgorde of codering ervan wijzigen. De verouderde header ondertekent de lege byte-reeks.- Organisaties zonder geconfigureerde verouderde webhook hebben geen organisatiegeheim. In
dat geval bevatten toolaanroepen alleen
X-ThunderPhone-Call-IDen geen handtekeningheaders. Configureer de verouderde webhook (PUT /v1/webhook) om een ondertekeningsgeheim te krijgen, of verifieer toolaanroepen met je eigen header 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)
...In Express laat een bodyparser req.body als een leeg object achter bij een GET,
dus geef Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) door.
Tooldispatch in webhook-modus (tools zonder een endpoint, geleverd
aan je organisatiewebhook als telephony.tool / web.tool) is een
gewone ondertekende webhook — de standaardmethode hierboven is van
toepassing. Zie Functietools voor beide
aanvraagvormen.
Verouderde handtekening: X-ThunderPhone-Signature
De oorspronkelijke header is de hexadecimale HMAC-SHA256 van alleen de onbewerkte body, met hetzelfde geheim. Deze bevat geen tijdstempel, dus iedereen die één ondertekend verzoek onderschept, kan het opnieuw afspelen. De header wordt nog steeds bij elk verzoek verzonden terwijl integraties naar V2 migreren.
- Lees de onbewerkte requestbody — exact de bytes die we je hebben gestuurd.
- Bereken
hmac_sha256(secret, body).hexdigest(). - Vergelijk in constante tijd met
X-ThunderPhone-Signature.
We ondertekenen exact de bytes die we verzenden. Die bytes zijn ook de
canonieke JSON-serialisatie van de payload — sleutels alfabetisch gesorteerd,
compacte scheidingstekens (, en : zonder spaties), UTF-8.
Als je framework alleen geparseerde JSON beschikbaar maakt, serialiseer dan canoniek opnieuw en
bereken daarover de HMAC:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Geef de voorkeur aan de onbewerkte body — dat is één stap minder en voorkomt problemen door het opnieuw omzetten van JSON-getallen in sommige talen. Hetzelfde geldt voor het bodyveld van 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)
endVeelvoorkomende valkuilen
Klokafwijking
Het venster van 5 minuten wordt gemeten aan de hand van de klok van je server. Houd die gesynchroniseerd met NTP; een klok die meer dan 5 minuten afwijkt, weigert elke aanvraag.
Een proxy herschrijft het pad
Het ondertekende pad is het pad dat we hebben aangevraagd: het pad van de URL die je in ThunderPhone hebt geconfigureerd. Als een reverse proxy of API-gateway een voorvoegsel verwijdert of toevoegt voordat je applicatie de aanvraag ziet, bouw dan vóór verificatie het pad dat we hebben verzonden opnieuw op (zet bijvoorbeeld het voorvoegsel terug).
Opnieuw serialiseren met standaardopmaak
Het parsen van de body en deze opnieuw wegschrijven met de
standaardinstellingen van je JSON-bibliotheek (spaties na , / :, sleutels in invoegvolgorde) levert
andere bytes op en breekt de HMAC. Verifieer de onbewerkte body — of als
je opnieuw moet serialiseren, volg dan exact onze canonieke vorm: gesorteerde
sleutels, compacte scheidingstekens, UTF-8.
Framework parseert JSON automatisch
De express.json()-middleware van Express verbruikt de bodystream
waardoor je de ruwe bytes verliest. Gebruik specifiek op de webhookroute
express.raw(), of buffer de ruwe body in een pre-middleware.
Hetzelfde geldt voor NestJS / Koa — raadpleeg hun documentatie over "raw body".
Vergelijking die niet timingveilig is
expected === signature in JS of expected == signature in
Python zijn vergelijkingen met variabele uitvoeringstijd. Gebruik respectievelijk crypto.timingSafeEqual
of hmac.compare_digest. Het prestatieverschil
is nihil.
Onjuist geheim voor tool-endpoints
Rechtstreekse aanroepen van tool-endpoints worden ondertekend met het organisatiebrede webhookgeheim
(GET /v1/webhook) — niet met een geheim per endpoint
uit /v1/developer/webhook-endpoints. Hergebruik dezelfde helper,
maar zorg ervoor dat je deze op toolroutes het organisatiegeheim geeft.
Queryreeksen bij GET/DELETE-tools
V2 ondertekent de queryreeks precies zoals deze is verzonden. De verouderde header ondertekent de lege body en nooit de queryreeks, dus het hashen van de URL voor de verouderde header komt nooit overeen.
Duplicaten binnen het venster
De tijdstempel stopt herhalingen die ouder zijn dan 5 minuten, niet een tweede kopie
binnen dat venster. Leveringen aan endpoints gebeuren minstens één keer, dus een
nieuwe poging voor dezelfde gebeurtenis kan ook aankomen; dedupliceer op de
event_id van de envelop.
Geen 401 teruggeven bij een mismatch
Een 200 teruggeven bij mislukte verificatie maakt de handler een doelwit voor replayaanvallen. Geef altijd een niet-2xx-respons terug als de verificatie mislukt.