Open in
Webhook-Signaturen überprüfen
Jede Webhook- und Tool-Anfrage, die ThunderPhone sendet, wird mit einem Zeitstempel signiert. Überprüfen Sie sie mit dem Rezept oder SDK-Helfer hier und lehnen Sie wiederholte Anfragen ab, die älter als fünf Minuten sind.
Jede Anfrage, die wir an Ihren Server senden (Webhook-Zustellungen, der ältere Single-URL-Webhook und Aufrufe von Tool-Endpunkten), enthält zwei Signatur-Header. Beide verwenden HMAC-SHA256 mit demselben Secret.
| Header | Signiert | Schutz vor Replay-Angriffen |
|---|---|---|
X-ThunderPhone-Signature-V2 (empfohlen) | Zeitstempel, Methode, Pfad, Abfrage und Body | Anfragen ablehnen, die älter als 5 Minuten sind |
X-ThunderPhone-Signature | Nur den Body | Keiner: Eine abgefangene Anfrage wird dauerhaft verifiziert |
Verifizieren Sie X-ThunderPhone-Signature-V2 in neuen Integrationen und stellen
Sie bestehende Integrationen darauf um. X-ThunderPhone-Signature wird weiterhin
unverändert mit jeder Anfrage gesendet, sodass darauf basierende Verifizierer
weiter funktionieren. Wir kündigen ein Datum an, bevor wir den Versand einstellen.
Signatur V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etist die Unix-Zeit in Sekunden, zu der wir die Anfrage gesendet haben. Eine erneut zugestellte Anfrage wird beim Versand erneut signiert und erhält ein eigenest.v1ist der hexadezimale HMAC-SHA256-Hash der untenstehenden signierten Zeichenfolge, der mit Ihrem Secret erzeugt wurde.
Der Wert ist eine durch Kommas getrennte Liste von key=value-Paaren. Ignorieren Sie Schlüssel, die Sie nicht erkennen, und akzeptieren Sie die Anfrage, wenn ein beliebiges v1 übereinstimmt: Bei einer zukünftigen Änderung können wir eine zweite Signatur hinzufügen.
Die signierte Zeichenfolge
Vier Felder, jeweils gefolgt von einem Zeilenumbruch (\n, Byte 0x0A), außer dem letzten:
<t>\n<METHOD>\n<path and query>\n<body>| Feld | Wert |
|---|---|
t | Der t-Wert aus dem Header |
METHOD | Die HTTP-Methode in Großbuchstaben: POST, GET, PUT, PATCH oder DELETE |
| path and query | Das Anfrageziel aus der HTTP-Anfragezeile: der Pfad, gefolgt von ? und der Abfragezeichenfolge, sofern vorhanden. Kein Schema, Host oder Fragment. Verwenden Sie es exakt so, wie es eingegangen ist, ohne es zu dekodieren oder Parameter neu anzuordnen |
body | Der unverarbeitete Anfrage-Body. GET- und DELETE-Tool-Aufrufe haben keinen Body, daher endet ihre signierte Zeichenfolge mit dem Zeilenumbruch nach dem Pfad |
So verifizieren Sie die Signatur:
- Lesen Sie vor jeder Verarbeitung den Header, die Methode, den unverarbeiteten Pfad und die Abfrage sowie den unverarbeiteten Body.
- Entnehmen Sie
tund jedesv1aus dem Header. - Lehnen Sie die Anfrage ab, wenn
tmehr als 300 Sekunden vor oder nach Ihrer Uhrzeit liegt. - Berechnen Sie mit Ihrem Secret den hexadezimalen HMAC-SHA256-Hash der signierten Zeichenfolge.
- Vergleichen Sie ihn in konstanter Zeit mit
v1. Antworten Sie mit401, wenn keine Übereinstimmung vorliegt.
Durchgerechnetes Beispiel
Secret whsec_example_4c1f2a9b. Wir senden diese Anfrage um
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"}Die signierte Zeichenfolge besteht aus diesen vier Zeilen, ohne Zeilenumbruch nach dem Body:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Ihr HMAC-SHA256-Hash lautet
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Prüfen Sie Ihre Implementierung dagegen oder reproduzieren Sie ihn mit openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Ein GET-Tool-Aufruf an /tools/lookup?customer_id=cust_123 in derselben
Sekunde hat einen leeren Body. Seine signierte Zeichenfolge lautet
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n und seine Signatur
lautet 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Mit den SDKs
Die Python- und TypeScript-SDKs enthalten eine Hilfsfunktion
(thunderphone 0.2.0 oder höher, @thunderphone/sdk 0.2.0 oder höher). Sie
prüft das 5-Minuten-Fenster, vergleicht in konstanter Zeit und akzeptiert eine Liste
von Secrets, damit Sie diese ohne Ausfallzeit rotieren können.
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
});Ohne 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-Anbindung
Jedes Beispiel liest den rohen Pfad und die Query sowie den rohen Body und ruft dann den SDK-Helper auf.
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) sowie ASGI-raw_path und query_string
(FastAPI) entsprechen exakt dem empfangenen Ziel. Djangos
get_full_path() und Flasks request.path dekodieren den Pfad zuerst, was
bei gewöhnlichen Pfaden dieselbe Zeichenfolge ergibt. Wenn Ihr Endpunktpfad
prozentkodierte oder reservierte Zeichen wie %2F, ; oder = enthält, lesen Sie
stattdessen das von Ihrem Server bereitgestellte rohe Ziel (zum Beispiel request.META["RAW_URI"] unter
Gunicorn).
Welches Secret?
Dasselbe Secret signiert beide Header.
| 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.
Tool-Aufrufe verifizieren
Wenn der Sprachagent eines Ihrer
Funktionstools direkt aufruft (das Tool verfügt über einen
endpoint), enthält die Anfrage neben Ihren konfigurierten
endpoint.headers diese ThunderPhone-Header:
X-ThunderPhone-Call-ID— die numerische ID des laufenden Anrufs.X-ThunderPhone-Signature-V2— die oben beschriebene zeitgestempelte Signatur, die mit Ihrem Webhook-Secret auf Organisationsebene erstellt wird.X-ThunderPhone-Signature— die ältere Signatur nur für den Body, mit demselben Schlüssel.
Zwei Aspekte unterscheiden sich von Webhooks:
GET- /DELETE-Tools haben keinen Body. Argumente werden als Abfrageparameter übertragen. Die V2-Signatur deckt sie über Pfad und Query ab, und das Body-Feld ist leer. Prüfen Sie mit dem unverarbeiteten Query-String: Das Parsen der Parameter und Erstellen eines neuen Query-Strings kann deren Reihenfolge oder Codierung ändern. Der ältere Header signiert die leere Bytefolge.- Organisationen ohne konfigurierten älteren Webhook haben kein Organisations-Secret.
In diesem Fall enthalten Tool-Aufrufe nur
X-ThunderPhone-Call-IDund keine Signatur-Header. Konfigurieren Sie den älteren Webhook (PUT /v1/webhook), um ein Signatur-Secret zu erhalten, oder authentifizieren Sie Tool-Aufrufe mit Ihrem eigenen Header überendpoint.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 hinterlässt ein Body-Parser bei einer GET-Anfrage req.body als leeres Objekt.
Übergeben Sie daher Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
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.
Legacy-Signatur: X-ThunderPhone-Signature
Der ursprüngliche Header ist der hexadezimale HMAC-SHA256 des reinen Rohtexts des Bodys, mit demselben Secret. Er enthält keinen Zeitstempel, sodass jeder, der eine signierte Anfrage abfängt, sie erneut senden kann. Während Integrationen zu V2 wechseln, wird er weiterhin mit jeder Anfrage gesendet.
- Lesen Sie den rohen Anfragetext — die exakten Bytes, die wir Ihnen gesendet haben.
- Berechnen Sie
hmac_sha256(secret, body).hexdigest(). - Vergleichen Sie in konstanter Zeit mit
X-ThunderPhone-Signature.
Wir signieren exakt die Bytes, die wir übertragen. Diese Bytes sind auch die
kanonische JSON-Serialisierung der Nutzdaten — Schlüssel alphabetisch sortiert,
kompakte Trennzeichen (, und : ohne Leerzeichen), UTF-8.
Wenn Ihr Framework nur geparstes JSON bereitstellt, serialisieren Sie es kanonisch erneut und
berechnen Sie den HMAC dafür:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Bevorzugen Sie den Rohtext des Bodys — das ist ein Schritt weniger und unempfindlich gegenüber Besonderheiten beim JSON-Zahlen-Roundtrip in einigen Sprachen. Dasselbe gilt für das Body-Feld von 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)
endHäufige Fallstricke
Zeitabweichung
Das 5-Minuten-Fenster wird anhand der Uhr Ihres Servers gemessen. Halten Sie sie mit NTP synchronisiert; eine Uhr, die mehr als 5 Minuten abweicht, lehnt jede Anfrage ab.
Ein Proxy schreibt den Pfad um
Der signierte Pfad ist der von uns angeforderte: der Pfad der URL, die Sie in ThunderPhone konfiguriert haben. Wenn ein Reverse Proxy oder API-Gateway ein Präfix entfernt oder hinzufügt, bevor Ihre Anwendung die Anfrage sieht, stellen Sie vor der Verifizierung den von uns gesendeten Pfad wieder her (fügen Sie beispielsweise das Präfix wieder hinzu).
Erneute Serialisierung mit Standardformatierung
Das Parsen des Bodys und erneute Ausgeben mit den Standardeinstellungen Ihrer
JSON-Bibliothek (Leerzeichen nach , / :, Schlüssel in Einfügereihenfolge) erzeugt
andere Bytes und beschädigt den HMAC. Verifizieren Sie den Roh-Body — oder gleichen
Sie, falls Sie erneut serialisieren müssen, exakt unsere kanonische Form 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 Geheimnis für Tool-Endpunkte
Direkte Aufrufe von Tool-Endpunkten werden mit dem Webhook-Geheimnis auf
Organisationsebene (GET /v1/webhook) signiert — nicht mit einem Geheimnis
pro Endpunkt aus /v1/developer/webhook-endpoints. Verwenden Sie denselben Helfer
erneut, stellen Sie jedoch sicher, dass Sie ihm auf Tool-Routen das Organisationsgeheimnis übergeben.
Abfragezeichenfolgen bei GET/DELETE-Tools
V2 signiert die Abfragezeichenfolge genau wie gesendet. Der Legacy-Header signiert den leeren Body und niemals die Abfragezeichenfolge. Daher wird das Hashing der URL für den Legacy-Header niemals übereinstimmen.
Duplikate innerhalb des Zeitfensters
Der Zeitstempel verhindert Wiederholungen, die älter als 5 Minuten sind, nicht jedoch eine zweite
Kopie innerhalb dieses Fensters. Endpunkt-Zustellungen erfolgen mindestens einmal, daher kann auch eine
Wiederholung desselben Ereignisses eintreffen; deduplizieren Sie anhand der
event_id des Envelopes.
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.