Open in
Verificer webhook-signaturer
Hver webhook- og værktøjsanmodning, ThunderPhone sender, er signeret med et tidsstempel. Verificer den med opskriften eller SDK-hjælpefunktionen her, og afvis gentagne anmodninger, der er ældre end fem minutter.
Hver anmodning, vi sender til din server (webhook-leveringer, det ældre webhook med én URL og kald til værktøjsendepunkter), indeholder to signaturheadere. Begge bruger HMAC-SHA256 med den samme hemmelighed.
| Header | Signerer | Beskyttelse mod gentagelsesangreb |
|---|---|---|
X-ThunderPhone-Signature-V2 (anbefalet) | Tidsstempel, metode, sti, forespørgsel og brødtekst | Afvis anmodninger, der er mere end 5 minutter gamle |
X-ThunderPhone-Signature | Kun brødtekst | Ingen: en opsnappet anmodning verificeres for altid |
Verificer X-ThunderPhone-Signature-V2 i nye integrationer, og flyt
eksisterende integrationer til den. X-ThunderPhone-Signature sendes stadig
med hver anmodning, uændret, så verifikatorer, der er bygget på den, fortsat
virker. Vi annoncerer en dato, før vi holder op med at sende den.
Signatur V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4eter Unix-tidspunktet i sekunder, da vi sendte anmodningen. En genforsøgt levering signeres igen, når den sendes, med sit egett.v1er den hexkodede HMAC-SHA256 for den signerede streng nedenfor, med din hemmelighed som nøgle.
Værdien er en kommasepareret liste over key=value-par. Ignorer nøgler, du
ikke genkender, og accepter anmodningen, hvis et hvilket som helst v1 matcher:
Vi kan tilføje en anden signatur ved en fremtidig ændring.
Den signerede streng
Fire felter, hver efterfulgt af én linjeskiftstegn (\n, byte 0x0A) undtagen
det sidste:
<t>\n<METHOD>\n<path and query>\n<body>| Felt | Værdi |
|---|---|
t | t-værdien fra headeren |
METHOD | HTTP-metoden med store bogstaver: POST, GET, PUT, PATCH eller DELETE |
| sti og forespørgsel | Anmodningsmålet fra HTTP-anmodningslinjen: stien, efterfulgt af ? og forespørgselsstrengen, når der er en. Intet skema, værtsnavn eller fragment. Brug det præcis, som det ankom, uden at afkode det eller ændre rækkefølgen af parametre |
body | Den rå anmodningskrop. GET- og DELETE-værktøjskald har ingen krop, så deres signerede streng slutter med linjeskiftet efter stien |
Sådan verificerer du:
- Før enhver parsing skal du læse headeren, metoden, den rå sti og forespørgsel samt den rå krop.
- Hent
tog allev1fra headeren. - Afvis anmodningen, hvis
ter mere end 300 sekunder før eller efter dit ur. - Beregn den hexkodede HMAC-SHA256 for den signerede streng med din hemmelighed.
- Sammenlign den med
v1i konstant tid. Svar401, hvis intet matcher.
Gennemarbejdet eksempel
Hemmelighed whsec_example_4c1f2a9b. Vi sender denne anmodning 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 signerede streng er disse fire linjer uden linjeskift efter kroppen:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Dens HMAC-SHA256 er
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Kontrollér din implementering mod den, eller genskab 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-værktøjskald til /tools/lookup?customer_id=cust_123 i samme
sekund har en tom krop. Dens signerede streng er
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, og dens signatur
er 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Med SDK'erne
Python- og TypeScript-SDK'erne indeholder en hjælper
(thunderphone 0.2.0 eller nyere, @thunderphone/sdk 0.2.0 eller nyere). Den
kontrollerer 5-minuttersvinduet, sammenligner i konstant tid og accepterer en
liste over hemmeligheder, så du kan rotere uden 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
});Uden en 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)
);
});
}Framework-integration
Hvert eksempel læser den rå sti og forespørgselsstreng samt den rå brødtekst og kalder derefter SDK-hjælpefunktionen.
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's raw_path og query_string
(FastAPI) er målet præcis som modtaget. Djangos
get_full_path() og Flasks request.path afkoder først stien, hvilket
giver den samme streng for almindelige stier. Hvis din endpointsti indeholder
procentkodede eller reserverede tegn som %2F, ; eller =, skal du i stedet læse det
rå mål, som din server leverer (for eksempel request.META["RAW_URI"] på
Gunicorn).
Hvilken hemmelighed?
Den samme hemmelighed signerer begge headere.
| 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.
Verificering af værktøjskald
Når agenten kalder et af dine
funktionsværktøjer direkte (værktøjet har et
endpoint), indeholder anmodningen disse ThunderPhone-headere sammen med
dine konfigurerede endpoint.headers:
X-ThunderPhone-Call-ID— det numeriske id for det aktive opkald.X-ThunderPhone-Signature-V2— den tidsstemplete signatur ovenfor, signeret med din webhook-hemmelighed på organisationsniveau.X-ThunderPhone-Signature— den ældre signatur, der kun dækker brødteksten, med samme nøgle.
To ting adskiller sig fra webhooks:
GET- /DELETE-værktøjer har ingen brødtekst. Argumenter sendes som forespørgselsparametre. V2-signaturen dækker dem via stien og forespørgslen, og brødtekstfeltet er tomt. Bekræft med den rå forespørgselsstreng: Fortolkning af parametrene og opbygning af en ny forespørgselsstreng kan ændre deres rækkefølge eller kodning. Den ældre header signerer den tomme bytestreng.- Organisationer uden en konfigureret ældre webhook har ingen organisationshemmelighed. I
det tilfælde indeholder værktøjskald kun
X-ThunderPhone-Call-IDog ingen signatur-headere. Konfigurer den ældre webhook (PUT /v1/webhook) for at få en signeringshemmelighed, eller godkend værktøjskald 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 efterlader en body-parser req.body som et tomt objekt ved en GET,
så send Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
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.
Ældre signatur: X-ThunderPhone-Signature
Den oprindelige header er den hexadecimale HMAC-SHA256 af alene den rå body med den samme hemmelighed. Den indeholder intet tidsstempel, så enhver, der opfanger én signeret anmodning, kan genafspille den. Den sendes stadig med hver anmodning, mens integrationer flytter til V2.
- Læs den rå body for anmodningen — de nøjagtige bytes, vi sendte til dig.
- Beregn
hmac_sha256(secret, body).hexdigest(). - Sammenlign i konstant tid med
X-ThunderPhone-Signature.
Vi signerer præcis de bytes, vi overfører. Disse bytes er også
payloadets kanoniske JSON-serialisering — nøgler sorteret
alfabetisk, kompakte separatorer (, og : uden mellemrum), UTF-8.
Hvis dit framework kun eksponerer parset JSON, skal du serialisere kanonisk igen og
beregne HMAC over 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 undgår særegenheder ved afrunding af JSON-tal ved tur-retur-konvertering i nogle sprog. Det samme gælder body-feltet 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)
endAlmindelige faldgruber
Kurforskydning
Vinduet på 5 minutter måles i forhold til dit servers ur. Hold det synkroniseret med NTP; et ur, der afviger med mere end 5 minutter, afviser alle anmodninger.
En proxy omskriver stien
Den signerede sti er den, vi anmodede om: stien i den URL, du konfigurerede i ThunderPhone. Hvis en reverse proxy eller API-gateway fjerner eller tilføjer et præfiks, før din applikation ser anmodningen, skal du genskabe den sti, vi sendte (for eksempel ved at tilføje præfikset igen), før du verificerer.
Genserialisering med standardformatering
Parsing af brødteksten og genudskrivning med dit JSON-biblioteks
standardindstillinger (mellemrum efter , / :, nøgler i indsættelsesrækkefølge) giver
forskellige bytes og ødelægger HMAC'en. Verificer den rå brødtekst — eller hvis
du skal genserialisere, 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 hemmelighed for værktøjsendepunkter
Direkte kald til værktøjsendepunkter signeres med webhookhemmeligheden
på organisationsniveau (GET /v1/webhook) — ikke med en hemmelighed pr. endepunkt
fra /v1/developer/webhook-endpoints. Genbrug den samme hjælpefunktion,
men sørg for, at du giver den organisationshemmeligheden på værktøjsruter.
Forespørgselsstrenge på GET/DELETE-værktøjer
V2 signerer forespørgselsstrengen nøjagtigt, som den sendes. Den ældre header signerer den tomme brødtekst og aldrig forespørgselsstrengen, så hashing af URL'en for den ældre header vil aldrig matche.
Dubletter inden for tidsvinduet
Tidsstemplet stopper genafspilninger, der er ældre end 5 minutter, men ikke en anden kopi
inden for det vindue. Leveringer til endepunkter sker mindst én gang, så et
genforsøg af den samme hændelse kan også ankomme; fjern dubletter baseret på
kuvertens event_id.
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.