Open in
Verifiera webhook-signaturer
Varje webhook- och verktygsbegäran som ThunderPhone skickar signeras med en tidsstämpel. Verifiera den med receptet eller SDK-hjälpfunktionen här och avvisa upprepade begäranden som är äldre än fem minuter.
Varje begäran vi skickar till din server (webhook-leveranser, den äldre webhooken med en enda URL och anrop till verktygsändpunkter) innehåller två signaturhuvuden. Båda använder HMAC-SHA256 med samma hemlighet.
| Header | Signerar | Skydd mot återuppspelning |
|---|---|---|
X-ThunderPhone-Signature-V2 (rekommenderas) | Tidsstämpel, metod, sökväg, frågesträng och brödtext | Avvisa begäranden som är äldre än 5 minuter |
X-ThunderPhone-Signature | Endast brödtext | Inget: en fångad begäran verifieras för alltid |
Verifiera X-ThunderPhone-Signature-V2 i nya integrationer och flytta
befintliga till den. X-ThunderPhone-Signature skickas fortfarande med varje
begäran, oförändrad, så verifierare som bygger på den fortsätter att fungera. Vi
meddelar ett datum innan vi slutar skicka den.
Signatur V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etär Unix-tiden, i sekunder, när vi skickade begäran. En återförsökt leverans signeras igen när den skickas, med ett egett.v1är den hexadecimala HMAC-SHA256-summan för den signerade strängen nedan, beräknad med din hemlighet som nyckel.
Värdet är en kommaseparerad lista med key=value-par. Ignorera nycklar som du
inte känner igen och godkänn begäran om någon v1 matchar: vi kan lägga till
en andra signatur vid en framtida ändring.
Den signerade strängen
Fyra fält, vart och ett följt av en nyrad (\n, byte 0x0A) utom det
sista:
<t>\n<METHOD>\n<path and query>\n<body>| Fält | Värde |
|---|---|
t | t-värdet från rubriken |
METHOD | HTTP-metoden med versaler: POST, GET, PUT, PATCH eller DELETE |
| sökväg och fråga | Begärandemålet från HTTP-begäranderaden: sökvägen, följt av ? och frågesträngen om en finns. Inget schema, värdnamn eller fragment. Använd det exakt som det kom, utan att avkoda det eller ändra ordningen på parametrarna |
body | Den råa begärandetexten. Verktygsanrop med GET och DELETE har ingen brödtext, så deras signerade sträng avslutas med nyraden efter sökvägen |
Så här verifierar du:
- Läs rubriken, metoden, den råa sökvägen och frågan samt den råa begärandetexten innan någon tolkning sker.
- Hämta
toch varjev1från rubriken. - Avvisa begäran om
tär mer än 300 sekunder före eller efter din klocka. - Beräkna den hexadecimala HMAC-SHA256-summan för den signerade strängen med din hemlighet.
- Jämför den med
v1i konstant tid. Svara med401om inget matchar.
Genomarbetat exempel
Hemlighet whsec_example_4c1f2a9b. Vi skickar denna begäran vid
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 signerade strängen är dessa fyra rader, utan nyrad efter brödtexten:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Dess HMAC-SHA256-summa är
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Kontrollera din implementering mot den, eller återskapa den med openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Ett GET-verktygsanrop till /tools/lookup?customer_id=cust_123 vid samma
sekund har en tom brödtext. Dess signerade sträng är
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n och dess signatur
är 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Med SDK:erna
Python- och TypeScript-SDK:erna innehåller en hjälpfunktion
(thunderphone 0.2.0 eller senare, @thunderphone/sdk 0.2.0 eller senare). Den
kontrollerar 5-minutersfönstret, jämför i konstant tid och accepterar en lista
med hemligheter så att du kan rotera utan driftstopp.
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
});Utan ett 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)
);
});
}Integrering i ramverk
Varje exempel läser den råa sökvägen och frågan samt den råa brödtexten och anropar sedan SDK-hjälpfunktionen.
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) samt ASGI:s raw_path och query_string
(FastAPI) är målet exakt så som det togs emot. Djangos
get_full_path() och Flasks request.path avkodar sökvägen först, vilket
ger samma sträng för vanliga sökvägar. Om sökvägen till din slutpunkt innehåller
procentkodade eller reserverade tecken som %2F, ; eller =, läser du i stället
det råa målet som servern tillhandahåller (till exempel request.META["RAW_URI"] i
Gunicorn).
Vilken hemlighet?
Samma hemlighet signerar båda rubrikerna.
| 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.
Verifiera verktygsanrop
När röstagenten anropar ett av dina
funktionsverktyg direkt (verktyget har en
endpoint) innehåller begäran följande ThunderPhone-rubriker utöver
din konfigurerade endpoint.headers:
X-ThunderPhone-Call-ID— det numeriska id:t för det pågående samtalet.X-ThunderPhone-Signature-V2— den tidsstämplade signaturen ovan, signerad med din webhookhemlighet på organisationsnivå.X-ThunderPhone-Signature— den äldre signaturen som endast omfattar begärandetexten, med samma hemlighet.
Två saker skiljer sig från webhooks:
GET- ochDELETE-verktyg har ingen begärandetext. Argument skickas som frågeparametrar. V2-signaturen omfattar dem via sökvägen och frågan, och fältet för begärandetext är tomt. Verifiera med den råa frågesträngen: att tolka parametrarna och bygga en ny frågesträng kan ändra deras ordning eller kodning. Den äldre rubriken signerar den tomma bytesträngen.- Organisationer utan en konfigurerad äldre webhook har ingen organisationshemlighet. I så fall innehåller verktygsanrop endast
X-ThunderPhone-Call-IDoch inga signaturrubriker. Konfigurera den äldre webhooken (PUT /v1/webhook) för att få en signeringshemlighet, eller autentisera verktygsanrop med din egen rubrik 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 lämnar en body-parser req.body som ett tomt objekt vid en GET,
så skicka Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
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.
Äldre signatur: X-ThunderPhone-Signature
Det ursprungliga huvudet är hexkodad HMAC-SHA256 av enbart den råa begärandetexten, med samma hemlighet. Det innehåller ingen tidsstämpel, så vem som helst som fångar en signerad begäran kan spela upp den igen. Det skickas fortfarande med varje begäran medan integrationer flyttas till V2.
- Läs den råa begärandetexten — de exakta byte vi skickade till dig.
- Beräkna
hmac_sha256(secret, body).hexdigest(). - Jämför i konstant tid med
X-ThunderPhone-Signature.
Vi signerar exakt de byte vi överför. Dessa byte är också payloadens
kanoniska JSON-serialisering — nycklar sorterade alfabetiskt, kompakta avgränsare
(, och : utan mellanslag), UTF-8. Om ditt ramverk bara exponerar parsad JSON ska du
serialisera om den 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 skyddat mot egenheter vid tur-och-retur-konvertering av JSON-tal i vissa språk. Detsamma gäller textfältet 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)
endVanliga fallgropar
Klockavvikelse
Fönstret på 5 minuter mäts mot serverns klocka. Håll den synkroniserad med NTP; en klocka som avviker med mer än 5 minuter avvisar alla begäranden.
En proxy skriver om sökvägen
Den signerade sökvägen är den vi begärde: sökvägen i den URL som du konfigurerade i ThunderPhone. Om en omvänd proxy eller API-gateway tar bort eller lägger till ett prefix innan din applikation ser begäran, återskapa sökvägen vi skickade (lägg till exempel tillbaka prefixet) före verifieringen.
Omserialisering med standardformatering
Att parsa innehållet och sedan serialisera det igen med JSON-bibliotekets
standardinställningar (mellanslag efter , / :, nycklar i insättningsordning) ger
andra byte och bryter HMAC. Verifiera det råa innehållet — eller, om
du måste omserialisera, 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 den organisationsövergripande webhook-
hemligheten (GET /v1/webhook) — inte med någon hemlighet per slutpunkt
från /v1/developer/webhook-endpoints. Återanvänd samma hjälpfunktion,
men se till att du skickar in organisationens hemlighet på verktygsrutter.
Frågesträngar för GET/DELETE-verktyg
V2 signerar frågesträngen exakt som den skickades. Den äldre HTTP-rubriken signerar det tomma innehållet och aldrig frågesträngen, så att hasha URL:en för den äldre HTTP-rubriken kommer aldrig att matcha.
Dubbletter inom fönstret
Tidsstämpeln stoppar upprepade begäranden som är äldre än 5 minuter, inte en andra kopia
inom det fönstret. Leveranser till slutpunkter sker minst en gång, så ett
nytt försök för samma händelse kan också komma; ta bort dubbletter baserat på
kuvertets event_id.
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.