Verifiser webhook-signaturer
Hver webhook- og verktøyforespørsel ThunderPhone sender, er signert. Verifiser signaturen én gang med oppskriften her, og bruk deretter den samme kontrollen på hvert endepunkt du kjører.
Hver forespørsel vi sender til serveren din — webhook-leveringer og
kall til verktøyendepunkter — har en HMAC-SHA256-signatur i
X-ThunderPhone-Signature-headeren. Få verifiseringen riktig én gang,
og bruk den samme hjelpefunksjonen i alle behandlere.
Algoritmen
- Les den rå forespørselsteksten — de nøyaktige bytene vi POST-et til deg.
- Beregn
hmac_sha256(secret, body).hexdigest(). - Sammenlign i konstant tid med
X-ThunderPhone-Signature. (En naiv strengsammenligning lekker tidsinformasjon.)
Vi signerer nøyaktig bytene vi overfører, så verifisering av den rå
forespørselsteksten fungerer alltid. Disse bytene er også den
kanoniske JSON-serialiseringen av nyttelasten — nøkler sortert
alfabetisk, kompakte skilletegn (, og : uten mellomrom), UTF-8.
Det gir deg en annen, fullt ut tilsvarende metode når rammeverket ditt
bare eksponerer tolket JSON: serialiser på nytt kanonisk og beregn HMAC
for det.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Foretrekk den rå forespørselsteksten — det er ett trinn mindre og upåvirket av særegenheter ved tur-retur-konvertering av JSON-tall i enkelte språk.
Hvilken hemmelighet?
| Kilde | Hemmelighet |
|---|---|
Webhook-endepunkt (/v1/developer/webhook-endpoints) | secret per endepunkt (48 heksadesimale tegn) som returneres én gang ved opprettelse |
| Eldre webhook med én URL | secret per organisasjon som returneres ved GET /v1/webhook |
Kall til verktøyendepunkt (direkte kall til din endpoint.url) | Webhook-hemmeligheten på organisasjonsnivå (den samme som for den eldre webhooken med én URL) — ikke en hemmelighet per endepunkt |
Lagre hemmeligheten i hemmelighetshåndtereren din eller i en miljøvariabel — aldri legg den inn i commit.
Referanseimplementasjoner
Alle fire verifiserer den rå forespørselsteksten:
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)
endRammeverksspesifikk tilkobling
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})Verifisering av verktøykall
Når agenten kaller ett av
funksjonsverktøyene dine direkte (verktøyet har et
endpoint), inneholder forespørselen to ThunderPhone-headere sammen med
den konfigurerte endpoint.headers:
X-ThunderPhone-Call-ID— den numeriske ID-en til den pågående samtalen.X-ThunderPhone-Signature— HMAC-SHA256, med webhook-hemmeligheten på organisasjonsnivå som nøkkel, over de nøyaktige byteverdiene i forespørselsbrødteksten.
Den samme verify()-hjelperen fungerer uten endringer, med to særtilfeller:
GET- /DELETE-verktøy har ingen brødtekst. Argumenter sendes som spørringsparametere, og signaturen beregnes over den tomme bytestrengen — altsåverify(b"", sig, secret)(Python) ellerverify(Buffer.alloc(0), sig, secret)(Node). Ikke hasj spørringsstrengen.- Organisasjoner uten en konfigurert eldre webhook har ingen organisasjonshemmelighet.
I så fall inneholder verktøykall bare
X-ThunderPhone-Call-IDog ingen signatur-header. Konfigurer den eldre webhooken (PUT /v1/webhook) for å få en signeringshemmelighet, eller autentiser verktøykall med din egen header 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)
...Verktøydistribusjon i webhook-modus (verktøy uten et endpoint, levert
til organisasjonens webhook som telephony.tool / web.tool) er en vanlig
signert webhook — standardoppskriften ovenfor gjelder. Se
Funksjonsverktøy for begge forespørselsformatene.
Vanlige fallgruver
Serialisering på nytt med standardformatering
Å tolke kroppen og skrive den ut på nytt med JSON-bibliotekets
standardinnstillinger (mellomrom etter , / :, nøkler i innsettingsrekkefølge) gir
andre byte og ødelegger HMAC-en. Verifiser den rå kroppen — eller hvis
du må serialisere på nytt, match vår kanoniske form nøyaktig: sorterte
nøkler, kompakte skilletegn, UTF-8.
Rammeverket tolker JSON automatisk
Express-mellomvaren express.json() bruker opp kroppstrømmen,
og du mister de rå bytene. Bruk express.raw() spesifikt på
webhook-ruten, eller bufre den rå kroppen i en forhåndsmellomvare.
Det samme gjelder NestJS / Koa — sjekk dokumentasjonen deres for "rå kropp".
Sammenligning som ikke er timingsikker
expected === signature i JS eller expected == signature i
Python er sammenligninger med variabel timing. Bruk henholdsvis crypto.timingSafeEqual
eller hmac.compare_digest. Ytelsesforskjellen
er lik null.
Feil hemmelighet for verktøyendepunkter
Direkte kall til verktøyendepunkter signeres med webhook-hemmeligheten
på organisasjonsnivå (GET /v1/webhook) — ikke med en hemmelighet per endepunkt
fra /v1/developer/webhook-endpoints. Gjenbruk den samme verify()-
funksjonen, men sørg for at du gir den organisasjonshemmeligheten på verktøyruter.
Hasher spørringsstrengen på GET/DELETE-verktøy
For verktøymetoder uten kropp dekker signaturen den tomme byte- strengen, slik at du beholder én universell oppskrift: HMAC den rå forespørselskroppen, uansett hva den er. Å hashe URL-en eller spørringsstrengen vil aldri samsvare.
Returnerer ikke 401 ved manglende samsvar
Å returnere 200 ved mislykket verifisering gjør behandleren til et mål for replay-angrep. Svar alltid med ikke-2xx hvis verifiseringen mislykkes.