Open in
Verifiser webhook-signaturer
Hver webhook- og verktøyforespørsel ThunderPhone sender, signeres med et tidsstempel. Verifiser den med oppskriften eller SDK-hjelpefunksjonen her, og avvis gjentatte forespørsler som er eldre enn fem minutter.
Hver forespørsel vi sender til serveren din (webhook-leveringer, den eldre webhooken med én URL og kall til verktøyendepunkter) har to signaturheadere. Begge er HMAC-SHA256 med samme hemmelighet.
| Header | Signerer | Beskyttelse mot replay-angrep |
|---|---|---|
X-ThunderPhone-Signature-V2 (anbefalt) | Tidsstempel, metode, sti, spørring og brødtekst | Avvis forespørsler som er mer enn 5 minutter gamle |
X-ThunderPhone-Signature | Kun brødtekst | Ingen: en oppfanget forespørsel valideres for alltid |
Verifiser X-ThunderPhone-Signature-V2 i nye integrasjoner, og flytt
eksisterende integrasjoner til den. X-ThunderPhone-Signature sendes fortsatt med hver
forespørsel, uendret, slik at verifiseringsmekanismer som er bygget på den, fortsatt fungerer. Vi vil
kunngjøre en dato før vi slutter å sende den.
Signatur V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4eter Unix-tiden, i sekunder, da vi sendte forespørselen. En levering som prøves på nytt, signeres på nytt når den sendes, med sin egent.v1er den heksadesimale HMAC-SHA256-en av den signerte strengen nedenfor, med hemmeligheten din som nøkkel.
Verdien er en kommaseparert liste over key=value-par. Ignorer nøkler du
ikke kjenner igjen, og godta forespørselen hvis en av v1-verdiene samsvarer:
vi kan legge til en andre signatur i en fremtidig endring.
Den signerte strengen
Fire felt, hvert etterfulgt av én linjeskifttegn (\n, byte 0x0A) unntatt
det siste:
<t>\n<METHOD>\n<path and query>\n<body>| Felt | Verdi |
|---|---|
t | t-verdien fra overskriften |
METHOD | HTTP-metoden med store bokstaver: POST, GET, PUT, PATCH eller DELETE |
| sti og spørring | Forespørselsmålet fra HTTP-forespørselslinjen: stien, deretter ? og spørringsstrengen når den finnes. Ingen skjema, vert eller fragment. Bruk den nøyaktig slik den kom, uten å dekode den eller endre rekkefølgen på parametere |
body | Den rå forespørselskroppen. GET- og DELETE-verktøykall har ingen kropp, så den signerte strengen deres slutter med linjeskiftet etter stien |
Slik verifiserer du:
- Før all parsing leser du overskriften, metoden, den rå stien og spørringen, og den rå kroppen.
- Hent
tog allev1-verdier fra overskriften. - Avvis forespørselen hvis
ter mer enn 300 sekunder før eller etter klokken din. - Beregn den heksadesimale HMAC-SHA256-en av den signerte strengen med hemmeligheten din.
- Sammenlign den med
v1i konstant tid. Svar med401hvis ingenting samsvarer.
Gjennomgått eksempel
Hemmelighet whsec_example_4c1f2a9b. Vi sender denne forespørselen på
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"}Den signerte strengen består av disse fire linjene, uten linjeskift etter kroppen:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}HMAC-SHA256-en er
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Kontroller implementeringen din mot den, eller gjenskap den med openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Et GET-verktøykall til /tools/lookup?customer_id=cust_123 i samme
sekund har en tom kropp. Den signerte strengen er
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, og signaturen er
3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Med SDK-ene
Python- og TypeScript-SDK-ene inkluderer en hjelpefunksjon
(thunderphone 0.2.0 eller nyere, @thunderphone/sdk 0.2.0 eller nyere). Den
kontrollerer 5-minuttersvinduet, sammenligner i konstant tid og godtar en liste
over hemmeligheter, slik at du kan rotere dem uten nedetid.
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
)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
});Uten et SDK
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)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)
);
});
}Rammeverksintegrasjon
Hvert eksempel leser den rå stien og spørringen og den rå brødteksten, og kaller deretter SDK-hjelperen.
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}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);
},
);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) og ASGI raw_path og query_string
(FastAPI) er målverdien nøyaktig slik den ble mottatt. Djangos
get_full_path() og Flasks request.path dekoder stien først, noe som
gir samme streng for vanlige stier. Hvis endepunktstien din inneholder
prosentkodede eller reserverte tegn som %2F, ; eller =, må du lese
det rå målet serveren din tilbyr i stedet (for eksempel request.META["RAW_URI"] på
Gunicorn).
Hvilken hemmelighet?
Det samme hemmelige nøkkelen signerer begge overskriftene.
| 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.
Verifisering av verktøykall
Når agenten påkaller ett av dine
funksjonsverktøy direkte (verktøyet har et
endpoint), inneholder forespørselen disse ThunderPhone-headerne sammen med
dine konfigurerte endpoint.headers:
X-ThunderPhone-Call-ID— den numeriske ID-en til den aktive samtalen.X-ThunderPhone-Signature-V2— den tidsstemplede signaturen ovenfor, med din webhook-hemmelighet på organisasjonsnivå som nøkkel.X-ThunderPhone-Signature— den eldre signaturen som kun dekker forespørselskroppen, med samme nøkkel.
To ting skiller seg fra webhooks:
GET- /DELETE-verktøy har ingen forespørselskropp. Argumenter sendes som spørringsparametere. V2-signaturen dekker dem via stien og spørringen, og kroppsfeltet er tomt. Verifiser med den rå spørringsstrengen: å tolke parameterne og bygge en ny spørringsstreng kan endre rekkefølgen eller kodingen. Den eldre headeren signerer den tomme bytestrengen.- Organisasjoner uten en konfigurert eldre webhook har ingen organisasjonshemmelighet. I
så fall inneholder verktøykall bare
X-ThunderPhone-Call-IDog ingen signaturheadere. Konfigurer den eldre webhooken (PUT /v1/webhook) for å få en signeringshemmelighet, eller autentiser verktøy- kall med din egen header viaendpoint.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)
...I Express lar en parser for forespørselskroppen req.body være et tomt objekt ved en GET,
så send Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
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.
Eldre signatur: X-ThunderPhone-Signature
Den opprinnelige headeren er hex-kodet HMAC-SHA256 av kun den rå kroppen, med samme hemmelighet. Den inneholder ikke noe tidsstempel, så alle som fanger opp én signert forespørsel, kan spille den av på nytt. Den sendes fortsatt med hver forespørsel mens integrasjoner flyttes til V2.
- Les den rå forespørselskroppen — de nøyaktige bytene vi sendte deg.
- Beregn
hmac_sha256(secret, body).hexdigest(). - Sammenlign i konstant tid med
X-ThunderPhone-Signature.
Vi signerer nøyaktig de bytene vi overfører. Disse bytene er også
payloadens kanoniske JSON-serialisering — nøkler sortert
alfabetisk, kompakte skilletegn (, og : uten mellomrom), UTF-8.
Hvis rammeverket ditt bare eksponerer tolket JSON, serialiser på nytt kanonisk og
beregn HMAC av den:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Foretrekk den rå kroppen — det er ett trinn mindre og unngår særheter ved tur-retur-konvertering av JSON-tall i enkelte språk. Det samme gjelder kroppsfeltet i V2.
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)
endVanlige fallgruver
Klokkeavvik
5-minuttersvinduet måles mot serverens klokke. Hold den synkronisert med NTP; en klokke som avviker med mer enn 5 minutter, avviser alle forespørsler.
En proxy skriver om stien
Den signerte stien er den vi ba om: stien til URL-en du konfigurerte i ThunderPhone. Hvis en omvendt proxy eller API-gateway fjerner eller legger til et prefiks før applikasjonen din ser forespørselen, må du rekonstruere stien vi sendte (for eksempel ved å legge tilbake prefikset) før du verifiserer.
Re-serialisering med standardformatering
Å parse brødteksten og serialisere den på nytt med standardinnstillingene i JSON-biblioteket ditt
(mellomrom etter , / :, nøkler i innsettingsrekkefølge) gir
andre byte og ødelegger HMAC-en. Verifiser den rå brødteksten — eller hvis
du må re-serialisere, 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 webhookhemmeligheten på
organisasjonsnivå (GET /v1/webhook) — ikke med en hemmelighet per endepunkt
fra /v1/developer/webhook-endpoints. Gjenbruk den samme hjelperen,
men sørg for at du sender inn organisasjonshemmeligheten på verktøyruter.
Spørringsstrenger på GET/DELETE-verktøy
V2 signerer spørringsstrengen nøyaktig slik den ble sendt. Den gamle headeren signerer den tomme brødteksten og aldri spørringsstrengen, så hashing av URL-en for den gamle headeren vil aldri samsvare.
Duplikater innenfor tidsvinduet
Tidsstempelet stopper gjentakelser eldre enn 5 minutter, ikke en ny kopi
innenfor det vinduet. Leveringer til endepunkter skjer minst én gang, så et
nytt forsøk på samme hendelse kan også komme; dedupliser på
konvoluttens event_id.
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.