Webhookhandtekeningen verifiëren
Elke webhook- en toolaanvraag die ThunderPhone verstuurt, is ondertekend. Verifieer de handtekening één keer met het recept hier en hergebruik vervolgens dezelfde controle voor elk endpoint dat je uitvoert.
Elke aanvraag die we naar je server sturen — webhookleveringen en
aanroepen van tool-eindpunten — bevat een HMAC-SHA256-handtekening in de
header X-ThunderPhone-Signature. Stel de verificatie één keer correct in en
gebruik dezelfde helper in elke handler.
Het algoritme
- Lees de onbewerkte aanvraagbody — de exacte bytes die we naar je hebben gepost.
- Bereken
hmac_sha256(secret, body).hexdigest(). - Vergelijk in constante tijd met
X-ThunderPhone-Signature. (Een naïeve tekenreeksvergelijking lekt timinginformatie.)
We ondertekenen precies de bytes die we verzenden, dus het verifiëren van de onbewerkte body
werkt altijd. Die bytes zijn ook de canonieke JSON-serialisatie
van de payload — sleutels alfabetisch gesorteerd, compacte scheidingstekens
(, en : zonder spaties), UTF-8. Dit biedt je een tweede, volledig
gelijkwaardige methode wanneer je framework alleen geparseerde JSON beschikbaar stelt:
serialiseer 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 met het opnieuw omzetten van JSON-getallen in sommige talen.
Welk geheim?
| 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.
Referentie-implementaties
Alle vier verifiëren de onbewerkte aanvraagbody:
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)
endFrameworkspecifieke integratie
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})Toolaanroepen verifiëren
Wanneer de agent rechtstreeks een van je
functietools aanroept (de tool heeft een
endpoint), bevat het verzoek naast je geconfigureerde
endpoint.headers twee ThunderPhone-headers:
X-ThunderPhone-Call-ID— de numerieke ID van het actieve gesprek.X-ThunderPhone-Signature— HMAC-SHA256, met je webhooksecret op organisatieniveau als sleutel, over de exacte bytes van de aanvraagbody.
Dezelfde verify()-helper werkt ongewijzigd, met twee aandachtspunten:
GET- /DELETE-tools hebben geen body. Argumenten worden als queryparameters doorgegeven en de handtekening wordt berekend over de lege bytestring — dusverify(b"", sig, secret)(Python) ofverify(Buffer.alloc(0), sig, secret)(Node). Hash de querystring niet.- Organisaties zonder geconfigureerde verouderde webhook hebben geen organisatiesecret.
In dat geval bevatten toolaanroepen alleen
X-ThunderPhone-Call-IDen geen handtekeningheader. Configureer de verouderde webhook (PUT /v1/webhook) om een ondertekeningssecret te krijgen, of verifieer toolaanroepen met je eigen header viaendpoint.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)
...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.
Veelvoorkomende valkuilen
Opnieuw serialiseren met standaardopmaak
De body parsen en opnieuw wegschrijven met de standaardinstellingen
van je JSON-bibliotheek (spaties na , / :, sleutels in invoegvolgorde) levert
andere bytes op en breekt de HMAC. Verifieer de ruwe body — of stem, als
je opnieuw moet serialiseren, exact af op 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 tooleindpunten
Rechtstreekse aanroepen naar tooleindpunten worden ondertekend met het webhookgeheim
op organisatieniveau (GET /v1/webhook) — niet met een geheim per eindpunt
uit /v1/developer/webhook-endpoints. Hergebruik dezelfde functie verify(),
maar zorg dat je deze op toolroutes het organisatiegeheim geeft.
De querystring hashen bij GET/DELETE-tools
Voor toolmethoden zonder body omvat de handtekening de lege bytestring, waardoor je één universeel recept behoudt: HMAC de ruwe requestbody, wat die ook is. De URL of querystring hashen komt nooit overeen.
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.