ThunderPhone 2.0 är här.Kom igång själv, från 2 cent/minut.Läs lanseringsnyheten

Operations

Verifiera webhook-signaturer

Varje webhook- och verktygsbegäran som ThunderPhone skickar är signerad. Verifiera signaturen en gång med anvisningarna här och återanvänd sedan samma kontroll på varje endpoint du kör.

Varje begäran vi skickar till din server — webhook-leveranser och anrop till verktygsslutpunkter — innehåller en HMAC-SHA256-signatur i headern X-ThunderPhone-Signature. Få verifieringen rätt en gång och använd samma hjälpfunktion i varje hanterare.

Algoritmen

  1. Läs den råa begärandetexten — exakt de byte vi POSTade till dig.
  2. Beräkna hmac_sha256(secret, body).hexdigest().
  3. Jämför i konstant tid med X-ThunderPhone-Signature. (Naiv strängjämförelse läcker tidsinformation.)

Vi signerar exakt de byte vi överför, så verifiering av den råa begärandetexten fungerar alltid. Dessa byte är också payloadens kanoniska JSON-serialisering — nycklar sorterade alfabetiskt, kompakta avgränsare (, och : utan mellanslag), UTF-8. Det ger dig ett andra, helt likvärdigt sätt när ditt ramverk bara exponerar parsad JSON: serialisera om kanoniskt och beräkna HMAC på den.

# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")

Föredra den råa begärandetexten — det är ett steg mindre och immunt mot särdrag vid JSON-talens tur-och-retur-konvertering i vissa språk.

Vilken hemlighet?

KällaHemlighet
Webhook-slutpunkt (/v1/developer/webhook-endpoints)secret per slutpunkt (48 hexadecimala tecken) returneras en gång vid skapande
Äldre webhook med en enda URLsecret per organisation returneras vid GET /v1/webhook
Anrop till verktygsslutpunkt (direktanrop till din endpoint.url)Webhook-hemligheten på organisationsnivå (samma som för den äldre webhooken med en enda URL) — inte en hemlighet per slutpunkt

Spara hemligheten i din hemlighetshanterare eller miljövariabel — comitta den aldrig.

Referensimplementationer

Alla fyra verifierar den råa begärandetexten:

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

Ramverksspecifik koppling

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

Verifiera verktygsanrop

När agenten anropar ett av dina funktionsverktyg direkt (verktyget har en endpoint) innehåller begäran två ThunderPhone-rubriker utöver dina konfigurerade endpoint.headers:

  • X-ThunderPhone-Call-ID — det numeriska id:t för det pågående samtalet.
  • X-ThunderPhone-Signature — HMAC-SHA256, med din webhook-hemlighet på organisationsnivå som nyckel, över de exakta byte som utgör begärandetexten.

Samma verify()-hjälpfunktion fungerar utan ändringar, med två detaljer:

  1. GET / DELETE-verktyg har ingen text. Argument skickas som frågeparametrar och signaturen beräknas över den tomma byte-strängen — alltså verify(b"", sig, secret) (Python) eller verify(Buffer.alloc(0), sig, secret) (Node). Hasha inte frågesträngen.
  2. Organisationer utan en konfigurerad äldre webhook har ingen organisationshemlighet. I det fallet innehåller verktygsanrop endast X-ThunderPhone-Call-ID och ingen signaturrubrik. Konfigurera den äldre webhooken (PUT /v1/webhook) för att få en signeringshemlighet, eller autentisera verktygsanrop med din egen rubrik 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)
    ...

Webhook-läge för verktygsdistribution (verktyg utan en endpoint, som levereras till din organisationswebhook som telephony.tool / web.tool) är en vanlig signerad webhook — standardreceptet ovan gäller. Se Funktionsverktyg för båda begärandeformaten.

Vanliga fallgropar

Omsserialisering med standardformatering

Att parsa brödtexten och sedan serialisera den igen med standardinställningarna i ditt JSON-bibliotek (mellanslag efter , / :, nycklar i insättningsordning) ger andra byte och förstör HMAC. Verifiera den råa brödtexten — eller, om du måste serialisera igen, matcha vår kanoniska form exakt: sorterade nycklar, kompakta avgränsare, UTF-8.

Ramverket parsar JSON automatiskt

Express-mellanprogrammet express.json() förbrukar brödtextströmmen och du förlorar de råa byten. Använd express.raw() specifikt på webhook-routen, eller buffra den råa brödtexten i ett för-mellanprogram. Samma sak gäller NestJS / Koa — läs deras dokumentation om "raw body".

Tidsosäker jämförelse

expected === signature i JS eller expected == signature i Python är jämförelser med varierande körtid. Använd crypto.timingSafeEqual respektive hmac.compare_digest. Prestandaskillnaden är obefintlig.

Fel hemlighet för verktygsslutpunkter

Direkta anrop till verktygsslutpunkter signeras med webhook-hemligheten på organisationsnivå (GET /v1/webhook) — inte med någon hemlighet per slutpunkt från /v1/developer/webhook-endpoints. Återanvänd samma verify() -funktion, men se till att du skickar in organisationshemligheten på verktygsrutter.

Hashning av frågesträngen för GET/DELETE-verktyg

För verktygsmetoder utan brödtext omfattar signaturen den tomma bytesträngen, vilket ger ett universellt recept: HMAC:a den råa begäransbrödtexten, oavsett vad den är. Att hasha URL:en eller frågesträngen kommer aldrig att matcha.

Returnerar inte 401 vid avvikelse

Att returnera 200 vid misslyckad verifiering gör hanteraren till ett mål för replay-attacker. Svara alltid med en statuskod utanför 2xx om verifieringen misslyckas.


Nästa steg