Webhook-Signaturen verifizieren
Jeder Webhook und jede Tool-Anfrage, die ThunderPhone sendet, wird signiert. Verifizieren Sie die Signatur einmal mit der Anleitung hier und verwenden Sie dieselbe Prüfung dann für jeden von Ihnen betriebenen Endpunkt.
Jede Anfrage, die wir an Ihren Server senden — Webhook-Zustellungen und
Aufrufe von Tool-Endpunkten — enthält im Header
X-ThunderPhone-Signature eine HMAC-SHA256-Signatur. Implementieren Sie
die Verifizierung einmal korrekt und verwenden Sie denselben Helfer in
jedem Handler.
Der Algorithmus
- Lesen Sie den unverarbeiteten Anfrage-Body — die exakten Bytes, die wir an Sie POSTen.
- Berechnen Sie
hmac_sha256(secret, body).hexdigest(). - Vergleichen Sie ihn in konstanter Zeit mit
X-ThunderPhone-Signature. (Ein naiver String-Vergleich gibt Timing-Informationen preis.)
Wir signieren exakt die Bytes, die wir übertragen. Daher funktioniert die
Verifizierung des unverarbeiteten Bodys immer. Diese Bytes sind außerdem die
kanonische JSON-Serialisierung der Payload — alphabetisch sortierte Schlüssel,
kompakte Trennzeichen (, und : ohne Leerzeichen), UTF-8. Das bietet Ihnen
eine zweite, vollständig gleichwertige Vorgehensweise, wenn Ihr Framework nur
geparstes JSON bereitstellt: Serialisieren Sie kanonisch erneut und berechnen
Sie darüber den HMAC.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Bevorzugen Sie den unverarbeiteten Body — das ist ein Schritt weniger und vermeidet Besonderheiten beim erneuten Durchlauf von JSON-Zahlen in einigen Sprachen.
Welches Secret?
| Quelle | Secret |
|---|---|
Webhook-Endpunkt (/v1/developer/webhook-endpoints) | Pro Endpunkt ein secret (48 Hex-Zeichen), das bei der Erstellung einmalig zurückgegeben wird |
| Legacy-Webhook mit einzelner URL | Pro Organisation ein secret, das bei GET /v1/webhook zurückgegeben wird |
Aufruf eines Tool-Endpunkts (direkter Aufruf Ihrer endpoint.url) | Das Webhook-Secret auf Organisationsebene (dasselbe wie beim Legacy-Webhook mit einzelner URL) — kein Secret pro Endpunkt |
Speichern Sie das Secret in Ihrem Secret-Manager oder einer Umgebungsvariable — committen Sie es niemals.
Referenzimplementierungen
Alle vier verifizieren den unverarbeiteten Anfrage-Body:
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)
endFramework-spezifische Einrichtung
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})Tool-Aufrufe verifizieren
Wenn der Agent eines Ihrer
Function Tools direkt aufruft (das Tool verfügt über einen
endpoint), enthält die Anfrage zusätzlich zu Ihren konfigurierten
endpoint.headers zwei ThunderPhone-Header:
X-ThunderPhone-Call-ID— die numerische ID des aktiven Anrufs.X-ThunderPhone-Signature— HMAC-SHA256 mit Ihrem organisationsweiten Webhook-Geheimnis als Schlüssel über die exakten Request-Body-Bytes.
Derselbe verify()-Helper funktioniert unverändert, mit zwei Besonderheiten:
GET- /DELETE-Tools haben keinen Body. Argumente werden als Abfrageparameter übertragen, und die Signatur wird über die leere Byte-Zeichenfolge berechnet — alsoverify(b"", sig, secret)(Python) oderverify(Buffer.alloc(0), sig, secret)(Node). Hashen Sie nicht die Abfragezeichenfolge.- Organisationen ohne konfigurierten Legacy-Webhook haben kein
Organisationsgeheimnis. In diesem Fall enthalten Tool-Aufrufe nur
X-ThunderPhone-Call-IDund keinen Signatur-Header. Konfigurieren Sie den Legacy-Webhook (PUT /v1/webhook), um ein Signaturgeheimnis zu erhalten, oder authentifizieren Sie Tool-Aufrufe über Ihren eigenen 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)
...Der Tool-Versand im Webhook-Modus (Tools ohne endpoint, die als
telephony.tool / web.tool an Ihren Organisations-Webhook zugestellt werden)
ist ein gewöhnlicher signierter Webhook — die obige Standardanleitung gilt.
Unter Function Tools finden Sie beide Anfrageformen.
Häufige Fallstricke
Erneutes Serialisieren mit Standardformatierung
Wenn Sie den Body parsen und mit den Standardeinstellungen Ihrer JSON-Bibliothek
erneut ausgeben (Leerzeichen nach , / :, Schlüssel in Einfügereihenfolge), entstehen
andere Bytes und das HMAC schlägt fehl. Prüfen Sie den rohen Body — oder gleichen Sie,
falls Sie ihn erneut serialisieren müssen, unsere kanonische Form exakt ab: sortierte
Schlüssel, kompakte Trennzeichen, UTF-8.
Framework parst JSON automatisch
Die express.json()-Middleware von Express verarbeitet den Body-Stream
und die rohen Bytes gehen verloren. Verwenden Sie gezielt express.raw() für die
Webhook-Route oder puffern Sie den rohen Body in einer vorgeschalteten Middleware.
Dasselbe gilt für NestJS / Koa — prüfen Sie deren Dokumentation zu „raw body“.
Nicht zeitkonstanter Vergleich
expected === signature in JS oder expected == signature in
Python sind zeitvariable Vergleiche. Verwenden Sie jeweils crypto.timingSafeEqual
oder hmac.compare_digest. Der Leistungsunterschied
ist null.
Falsches Secret für Tool-Endpunkte
Direkte Aufrufe von Tool-Endpunkten werden mit dem Webhook-Secret auf
Organisationsebene (GET /v1/webhook) signiert — nicht mit einem
Endpunkt-spezifischen Secret aus /v1/developer/webhook-endpoints. Verwenden Sie dieselbe
verify()-Funktion erneut, stellen Sie jedoch sicher, dass Sie ihr bei Tool-Routen
das Organisations-Secret übergeben.
Hashing des Query-Strings bei GET/DELETE-Tools
Bei Tool-Methoden ohne Body umfasst die Signatur den leeren Byte-String, wodurch ein einheitliches Verfahren erhalten bleibt: Bilden Sie das HMAC über den rohen Request-Body, unabhängig von dessen Inhalt. Das Hashing der URL oder des Query-Strings wird niemals übereinstimmen.
Bei Nichtübereinstimmung kein 401 zurückgeben
Wenn Sie bei fehlgeschlagener Verifizierung 200 zurückgeben, wird der Handler zu einem Ziel für Replay-Angriffe. Antworten Sie immer mit einem Nicht-2xx-Status, wenn die Verifizierung fehlschlägt.
Nächste Schritte
Zustellsemantik, Wiederholungsversuche, Quell-IP-Adressen.
Mehrere URLs verwalten, Secrets rotieren.
Die zwei Pfade für Tool-Aufrufe und ihre Anfrageformate.
Erstellen Sie eine vollständige Tool-gestützte Integration von Anfang bis Ende.