Bekræft webhook-signaturer
Hver webhook- og værktøjsanmodning, som ThunderPhone sender, er signeret. Bekræft signaturen én gang med opskriften her, og genbrug derefter den samme kontrol på alle de slutpunkter, du kører.
Alle anmodninger, vi sender til din server — webhook-leveringer og
kald af værktøjsendepunkter — indeholder en HMAC-SHA256-signatur i
headeren X-ThunderPhone-Signature. Få verificeringen rigtig én gang,
og brug den samme hjælpefunktion i alle handlere.
Algoritmen
- Læs den rå anmodningsbody — de præcise bytes, vi POSTede til dig.
- Beregn
hmac_sha256(secret, body).hexdigest(). - Sammenlign i konstant tid med
X-ThunderPhone-Signature. (En naiv strengsammenligning afslører tidsoplysninger.)
Vi signerer præcis de bytes, vi transmitterer, så verificering af den rå body
virker altid. Disse bytes er også payloadets kanoniske JSON-serialisering
— nøgler sorteret alfabetisk, kompakte separatorer
(, og : uden mellemrum), UTF-8. Det giver dig en anden, fuldt
ækvivalent metode, når dit framework kun eksponerer parset JSON:
serialisér kanonisk igen, og beregn HMAC for det.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Foretræk den rå body — det er ét trin mindre og upåvirket af særheder ved JSON-tal, der rundturseraliseres i nogle sprog.
Hvilken hemmelighed?
| Kilde | Hemmelighed |
|---|---|
Webhook-endepunkt (/v1/developer/webhook-endpoints) | secret pr. endepunkt (48 hextegn), som returneres én gang ved oprettelse |
| Ældre webhook med enkelt URL | secret pr. organisation, returneret ved GET /v1/webhook |
Kald af værktøjsendepunkt (direkte kald til din endpoint.url) | Webhook-hemmeligheden på organisationsniveau (den samme som for den ældre webhook med enkelt URL) — ikke en hemmelighed pr. endepunkt |
Gem hemmeligheden i din secret manager eller miljøvariabel — commit den aldrig.
Referenceimplementeringer
Alle fire verificerer den rå anmodningsbody:
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)
endFrameworkspecifik tilslutning
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})Verificering af værktøjskald
Når agenten kalder et af dine
funktionsværktøjer direkte (værktøjet har et
endpoint), indeholder anmodningen to ThunderPhone-headere sammen med
dine konfigurerede endpoint.headers:
X-ThunderPhone-Call-ID— det numeriske id for det aktive opkald.X-ThunderPhone-Signature— HMAC-SHA256, med din webhook-hemmelighed på organisationsniveau som nøgle, over de nøjagtige bytes i anmodningens brødtekst.
Den samme verify()-hjælper fungerer uændret med to forskelle:
GET- /DELETE-værktøjer har ingen brødtekst. Argumenter sendes som forespørgselsparametre, og signaturen beregnes over den tomme bytestreng — altsåverify(b"", sig, secret)(Python) ellerverify(Buffer.alloc(0), sig, secret)(Node). Hash ikke forespørgselsstrengen.- Organisationer uden en konfigureret ældre webhook har ingen organisationshemmelighed. I det tilfælde indeholder værktøjskald kun
X-ThunderPhone-Call-IDog ingen signatur-header. Konfigurer den ældre webhook (PUT /v1/webhook) for at få en signeringshemmelighed, eller godkend værktøjskald 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)
...Værktøjsafsendelse i webhook-tilstand (værktøjer uden et endpoint, leveret
til din organisationswebhook som telephony.tool / web.tool) er en almindelig
signeret webhook — standardopskriften ovenfor gælder. Se
Funktionsværktøjer for begge anmodningsformer.
Almindelige faldgruber
Gen-serialisering med standardformatering
Hvis du parser brødteksten og dumper den igen med JSON-bibliotekets
standardindstillinger (mellemrum efter , / :, nøgler i indsættelsesrækkefølge), produceres
der andre bytes, og HMAC'en brydes. Verificer den rå brødtekst — eller hvis
du skal gen-serialisere, skal du matche vores kanoniske form nøjagtigt: sorterede
nøgler, kompakte separatorer, UTF-8.
Framework parser automatisk JSON
Express' express.json()-middleware forbruger brødtekststrømmen,
så du mister de rå bytes. Brug specifikt express.raw() på webhook-ruten,
eller buffer den rå brødtekst i en pre-middleware.
Det samme gælder NestJS / Koa — se deres dokumentation om "raw body".
Timing-usikker sammenligning
expected === signature i JS eller expected == signature i
Python er sammenligninger med variabel timing. Brug henholdsvis crypto.timingSafeEqual
eller hmac.compare_digest. Ydelsesforskellen
er nul.
Forkert secret for værktøjsendepunkter
Direkte kald til værktøjsendepunkter signeres med webhook-secret på
organisationsniveau (GET /v1/webhook) — ikke med et endpoint-specifikt secret
fra /v1/developer/webhook-endpoints. Genbrug den samme verify()
funktion, men sørg for at give den organisations-secretet på værktøjsruter.
Hashing af query-strengen på GET/DELETE-værktøjer
For værktøjsmetoder uden brødtekst dækker signaturen den tomme byte- streng, så der bevares én universel opskrift: HMAC den rå anmodningsbrødtekst, uanset hvad den er. Hashing af URL'en eller query-strengen vil aldrig matche.
Returnerer ikke 401 ved mismatch
Hvis du returnerer 200 ved mislykket verificering, bliver handleren et mål for replay. Svar altid med en ikke-2xx-status, hvis verificeringen mislykkes.