ThunderPhone 2.0 is live.Direct zelf aan de slag, vanaf 2 cent/min.Lees de aankondiging

Operations

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

  1. Lees de onbewerkte aanvraagbody — de exacte bytes die we naar je hebben gepost.
  2. Bereken hmac_sha256(secret, body).hexdigest().
  3. 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?

BronGeheim
Webhookeindpunt (/v1/developer/webhook-endpoints)secret per eindpunt (48 hexadecimale tekens), eenmalig teruggegeven bij het aanmaken
Verouderde webhook met één URLsecret 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:

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

Frameworkspecifieke integratie

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})

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:

  1. GET- / DELETE-tools hebben geen body. Argumenten worden als queryparameters doorgegeven en de handtekening wordt berekend over de lege bytestring — dus verify(b"", sig, secret) (Python) of verify(Buffer.alloc(0), sig, secret) (Node). Hash de querystring niet.
  2. Organisaties zonder geconfigureerde verouderde webhook hebben geen organisatiesecret. In dat geval bevatten toolaanroepen alleen X-ThunderPhone-Call-ID en geen handtekeningheader. Configureer de verouderde webhook (PUT /v1/webhook) om een ondertekeningssecret te krijgen, of verifieer toolaanroepen met je eigen 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)
    ...

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.


Volgende stappen