ThunderPhone 2.0 ist live.Direkt im Self-Service – ab 2 ¢/Min..Ankündigung lesen

Operations

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

  1. Lesen Sie den unverarbeiteten Anfrage-Body — die exakten Bytes, die wir an Sie POSTen.
  2. Berechnen Sie hmac_sha256(secret, body).hexdigest().
  3. 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?

QuelleSecret
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 URLPro 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:

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

Framework-spezifische Einrichtung

FastAPI
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}
Express
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);
  },
);
Django
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:

  1. GET- / DELETE-Tools haben keinen Body. Argumente werden als Abfrageparameter übertragen, und die Signatur wird über die leere Byte-Zeichenfolge berechnet — also verify(b"", sig, secret) (Python) oder verify(Buffer.alloc(0), sig, secret) (Node). Hashen Sie nicht die Abfragezeichenfolge.
  2. Organisationen ohne konfigurierten Legacy-Webhook haben kein Organisationsgeheimnis. In diesem Fall enthalten Tool-Aufrufe nur X-ThunderPhone-Call-ID und 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 via endpoint.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