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

Operations

Webhookhandtekeningen verifiëren

Elke webhook- en toolaanvraag die ThunderPhone verstuurt, wordt ondertekend met een tijdstempel. Verifieer deze met het recept of de SDK-helper hier en wijs herhaalde aanvragen ouder dan vijf minuten af.

Elk verzoek dat we naar je server sturen (webhookleveringen, de verouderde webhook met één URL en aanroepen naar tool-endpoints) bevat twee handtekeningheaders. Beide gebruiken HMAC-SHA256 met hetzelfde geheim.

HeaderOndertekentBescherming tegen replay-aanvallen
X-ThunderPhone-Signature-V2 (aanbevolen)Tijdstempel, methode, pad, query en bodyVerwerp verzoeken die ouder zijn dan 5 minuten
X-ThunderPhone-SignatureAlleen bodyGeen: een onderschept verzoek blijft altijd verifieerbaar

Verifieer X-ThunderPhone-Signature-V2 in nieuwe integraties en migreer bestaande integraties ernaartoe. X-ThunderPhone-Signature wordt nog steeds ongewijzigd met elk verzoek verzonden, zodat verificateurs die hierop zijn gebaseerd blijven werken. We kondigen een datum aan voordat we stoppen met het verzenden ervan.

Handtekening V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t is de Unix-tijd, in seconden, waarop we het verzoek hebben verzonden. Een opnieuw geprobeerd verzoek wordt opnieuw ondertekend wanneer het wordt verzonden, met een eigen t.
  • v1 is de hexadecimale HMAC-SHA256 van de onderstaande ondertekende tekenreeks, met jouw geheim als sleutel.

De waarde is een door komma's gescheiden lijst met key=value-paren. Negeer sleutels die je niet herkent en accepteer het verzoek als een v1 overeenkomt: we kunnen bij een toekomstige wijziging een tweede handtekening toevoegen.

De ondertekende tekenreeks

Vier velden, elk gevolgd door één nieuwe regel (\n, byte 0x0A), behalve het laatste:

<t>\n<METHOD>\n<path and query>\n<body>
VeldWaarde
tDe t-waarde uit de header
METHODDe HTTP-methode in hoofdletters: POST, GET, PUT, PATCH of DELETE
path and queryHet verzoekdoel uit de HTTP-verzoekregel: het pad, gevolgd door ? en de querytekenreeks als die aanwezig is. Geen schema, host of fragment. Gebruik dit exact zoals het is ontvangen, zonder het te decoderen of parameters te herschikken
bodyDe onbewerkte aanvraagbody. Toolaanroepen met GET en DELETE hebben geen body, dus hun ondertekende tekenreeks eindigt met de nieuwe regel na het pad

Verifiëren:

  1. Lees vóór enige parsing de header, de methode, het onbewerkte pad en de query, en de onbewerkte body.
  2. Neem t en elke v1 uit de header.
  3. Wijs het verzoek af als t meer dan 300 seconden voor of na jouw klok ligt.
  4. Bereken de hexadecimale HMAC-SHA256 van de ondertekende tekenreeks met jouw geheim.
  5. Vergelijk deze in constante tijd met v1. Antwoord met 401 als niets overeenkomt.

Uitgewerkt voorbeeld

Geheim whsec_example_4c1f2a9b. We verzenden dit verzoek op 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"}

De ondertekende tekenreeks bestaat uit deze vier regels, zonder nieuwe regel na de body:

1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}

De HMAC-SHA256 ervan is 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Controleer je implementatie hiermee, of reproduceer deze met openssl:

printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'

Een GET-toolaanroep naar /tools/lookup?customer_id=cust_123 in dezelfde seconde heeft een lege body. De ondertekende tekenreeks is 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n en de handtekening is 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

Met de SDK's

De Python- en TypeScript-SDK's bevatten een helper (thunderphone 0.2.0 of hoger, @thunderphone/sdk 0.2.0 of hoger). Deze controleert het venster van 5 minuten, vergelijkt in constante tijd en accepteert een lijst met geheimen zodat je zonder downtime kunt roteren.

Python
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
)
Node.js
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
});

Zonder een SDK

Python
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)
Node.js
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-integratie

Elk voorbeeld leest het onbewerkte pad en de query en de onbewerkte body, en roept vervolgens de SDK-helper aan.

FastAPI
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}
Express
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);
  },
);
Django
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) en de ASGI-waarden raw_path en query_string (FastAPI) zijn precies het aanvraagdoel zoals ontvangen. Django's get_full_path() en Flask's request.path decoderen het pad eerst, wat voor gewone paden dezelfde tekenreeks oplevert. Als het endpointpad procentgecodeerde of gereserveerde tekens bevat, zoals %2F, ; of =, lees dan in plaats daarvan het onbewerkte doel dat je server beschikbaar stelt (bijvoorbeeld request.META["RAW_URI"] op Gunicorn).

Welk geheim?

Hetzelfde geheim ondertekent beide headers.

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.

Toolaanroepen verifiëren

Wanneer de agent een van je functietools rechtstreeks aanroept (de tool heeft een endpoint), bevat het verzoek deze ThunderPhone-headers naast je geconfigureerde endpoint.headers:

  • X-ThunderPhone-Call-ID — de numerieke id van de actieve oproep.
  • X-ThunderPhone-Signature-V2 — de tijdgestempelde handtekening hierboven, ondertekend met je webhookgeheim op organisatieniveau.
  • X-ThunderPhone-Signature — de verouderde handtekening die alleen de body omvat, met dezelfde sleutel.

Twee dingen verschillen van webhooks:

  1. GET- / DELETE-tools hebben geen body. Argumenten worden als queryparameters verzonden. De V2-handtekening omvat deze via het pad en de query, en het bodyveld is leeg. Verifieer met de onbewerkte queryreeks: het parsen van de parameters en het opbouwen van een nieuwe queryreeks kan de volgorde of codering ervan wijzigen. De verouderde header ondertekent de lege byte-reeks.
  2. Organisaties zonder geconfigureerde verouderde webhook hebben geen organisatiegeheim. In dat geval bevatten toolaanroepen alleen X-ThunderPhone-Call-ID en geen handtekeningheaders. Configureer de verouderde webhook (PUT /v1/webhook) om een ondertekeningsgeheim te krijgen, of verifieer toolaanroepen met je eigen header via endpoint.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 laat een bodyparser req.body als een leeg object achter bij een GET, dus geef Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) door.

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.

Verouderde handtekening: X-ThunderPhone-Signature

De oorspronkelijke header is de hexadecimale HMAC-SHA256 van alleen de onbewerkte body, met hetzelfde geheim. Deze bevat geen tijdstempel, dus iedereen die één ondertekend verzoek onderschept, kan het opnieuw afspelen. De header wordt nog steeds bij elk verzoek verzonden terwijl integraties naar V2 migreren.

  1. Lees de onbewerkte requestbody — exact de bytes die we je hebben gestuurd.
  2. Bereken hmac_sha256(secret, body).hexdigest().
  3. Vergelijk in constante tijd met X-ThunderPhone-Signature.

We ondertekenen exact de bytes die we verzenden. Die bytes zijn ook de canonieke JSON-serialisatie van de payload — sleutels alfabetisch gesorteerd, compacte scheidingstekens (, en : zonder spaties), UTF-8. Als je framework alleen geparseerde JSON beschikbaar maakt, serialiseer dan 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 door het opnieuw omzetten van JSON-getallen in sommige talen. Hetzelfde geldt voor het bodyveld van V2.

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

Veelvoorkomende valkuilen

Klokafwijking

Het venster van 5 minuten wordt gemeten aan de hand van de klok van je server. Houd die gesynchroniseerd met NTP; een klok die meer dan 5 minuten afwijkt, weigert elke aanvraag.

Een proxy herschrijft het pad

Het ondertekende pad is het pad dat we hebben aangevraagd: het pad van de URL die je in ThunderPhone hebt geconfigureerd. Als een reverse proxy of API-gateway een voorvoegsel verwijdert of toevoegt voordat je applicatie de aanvraag ziet, bouw dan vóór verificatie het pad dat we hebben verzonden opnieuw op (zet bijvoorbeeld het voorvoegsel terug).

Opnieuw serialiseren met standaardopmaak

Het parsen van de body en deze opnieuw wegschrijven met de standaardinstellingen van je JSON-bibliotheek (spaties na , / :, sleutels in invoegvolgorde) levert andere bytes op en breekt de HMAC. Verifieer de onbewerkte body — of als je opnieuw moet serialiseren, volg dan exact 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 tool-endpoints

Rechtstreekse aanroepen van tool-endpoints worden ondertekend met het organisatiebrede webhookgeheim (GET /v1/webhook) — niet met een geheim per endpoint uit /v1/developer/webhook-endpoints. Hergebruik dezelfde helper, maar zorg ervoor dat je deze op toolroutes het organisatiegeheim geeft.

Queryreeksen bij GET/DELETE-tools

V2 ondertekent de queryreeks precies zoals deze is verzonden. De verouderde header ondertekent de lege body en nooit de queryreeks, dus het hashen van de URL voor de verouderde header komt nooit overeen.

Duplicaten binnen het venster

De tijdstempel stopt herhalingen die ouder zijn dan 5 minuten, niet een tweede kopie binnen dat venster. Leveringen aan endpoints gebeuren minstens één keer, dus een nieuwe poging voor dezelfde gebeurtenis kan ook aankomen; dedupliceer op de event_id van de envelop.

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