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
- Läs den råa begärandetexten — exakt de byte vi POSTade till dig.
- Beräkna
hmac_sha256(secret, body).hexdigest(). - 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älla | Hemlighet |
|---|---|
Webhook-slutpunkt (/v1/developer/webhook-endpoints) | secret per slutpunkt (48 hexadecimala tecken) returneras en gång vid skapande |
| Äldre webhook med en enda URL | secret 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:
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)
endRamverksspecifik koppling
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}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);
},
);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:
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) ellerverify(Buffer.alloc(0), sig, secret)(Node). Hasha inte frågesträngen.- Organisationer utan en konfigurerad äldre webhook har ingen
organisationshemlighet. I det fallet innehåller verktygsanrop endast
X-ThunderPhone-Call-IDoch ingen signaturrubrik. Konfigurera den äldre webhooken (PUT /v1/webhook) för att få en signeringshemlighet, eller autentisera verktygsanrop med din egen rubrik viaendpoint.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.