Open in
Ověření podpisů webhooků
Každý webhook a požadavek nástroje, který ThunderPhone odesílá, je podepsán časovým razítkem. Ověřte jej pomocí zde uvedeného postupu nebo pomocné funkce SDK a odmítněte opakované požadavky starší než pět minut.
Každý požadavek, který odesíláme na váš server (doručení webhooků, starší webhook s jedinou adresou URL a volání koncových bodů nástrojů), obsahuje dvě hlavičky podpisu. Obě používají HMAC-SHA256 se stejným tajným klíčem.
| Hlavička | Podepisuje | Ochrana proti opakování |
|---|---|---|
X-ThunderPhone-Signature-V2 (doporučeno) | Časové razítko, metoda, cesta, dotaz a tělo | Odmítněte požadavky starší než 5 minut |
X-ThunderPhone-Signature | Pouze tělo | Žádná: zachycený požadavek se ověří navždy |
V nových integracích ověřujte X-ThunderPhone-Signature-V2 a stávající
integrace na něj převeďte. X-ThunderPhone-Signature se stále odesílá s každým
požadavkem beze změny, takže ověřovací mechanismy založené na něm nadále fungují. Datum, kdy jej přestaneme odesílat, oznámíme předem.
Podpis V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etje čas Unix v sekundách, kdy jsme požadavek odeslali. Opakované doručení se při odeslání podepíše znovu s vlastnímt.v1je hexadecimální HMAC-SHA256 podepsaného řetězce níže, vytvořený pomocí vašeho tajného klíče.
Hodnota je seznam párů key=value oddělených čárkami. Ignorujte klíče,
které neznáte, a přijměte požadavek, pokud se shoduje jakékoli v1: při
budoucí změně můžeme přidat druhý podpis.
Podepsaný řetězec
Čtyři pole, po každém následuje jeden nový řádek (\n, bajt 0x0A), kromě
posledního:
<t>\n<METHOD>\n<path and query>\n<body>| Pole | Hodnota |
|---|---|
t | Hodnota t z hlavičky |
METHOD | Metoda HTTP velkými písmeny: POST, GET, PUT, PATCH nebo DELETE |
| path and query | Cíl požadavku z řádku požadavku HTTP: cesta, poté ? a řetězec dotazu, pokud existuje. Bez schématu, hostitele nebo fragmentu. Použijte jej přesně tak, jak dorazil, bez dekódování nebo změny pořadí parametrů |
body | Nezpracované tělo požadavku. Volání nástrojů GET a DELETE nemají tělo, takže jejich podepsaný řetězec končí novým řádkem za cestou |
Ověření:
- Před jakýmkoli parsováním načtěte hlavičku, metodu, nezpracovanou cestu a dotaz i nezpracované tělo.
- Z hlavičky vezměte
ta všechnav1. - Odmítněte požadavek, pokud se
tliší od vašeho času o více než 300 sekund. - Vypočítejte hexadecimální HMAC-SHA256 podepsaného řetězce pomocí vašeho tajného klíče.
- Porovnejte jej s
v1v konstantním čase. Pokud se nic neshoduje, odpovězte401.
Praktický příklad
Tajný klíč whsec_example_4c1f2a9b. Tento požadavek odesíláme v čase
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"}Podepsaný řetězec tvoří tyto čtyři řádky bez nového řádku za tělem:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Jeho HMAC-SHA256 je
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Ověřte podle něj svou implementaci nebo jej reprodukujte pomocí openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Volání nástroje GET na /tools/lookup?customer_id=cust_123 ve stejné
sekundě má prázdné tělo. Jeho podepsaný řetězec je
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n a jeho podpis je
3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Se sadami SDK
Sady SDK pro Python a TypeScript obsahují pomocnou funkci
(thunderphone 0.2.0 nebo novější, @thunderphone/sdk 0.2.0 nebo novější). Ta
kontroluje pětiminutové okno, porovnává v konstantním čase a přijímá seznam
tajných klíčů, takže je můžete střídat bez výpadku.
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
});Bez 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)
);
});
}Zapojení frameworku
Každý příklad načte nezpracovanou cestu a dotaz i nezpracované tělo a poté zavolá pomocnou funkci SDK.
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) a ASGI raw_path a query_string
(FastAPI) představují cíl přesně v podobě, v jaké byl přijat. Metoda Django
get_full_path() a Flask request.path nejprve dekódují cestu, což
u běžných cest poskytne stejný řetězec. Pokud cesta vašeho endpointu obsahuje
procentuálně kódované nebo vyhrazené znaky, například %2F, ; nebo =, načtěte
místo toho nezpracovaný cíl, který váš server poskytuje (například request.META["RAW_URI"] na
Gunicornu).
Který tajný klíč?
Obě hlavičky jsou podepsány stejným tajným klíčem.
| Zdroj | Tajný klíč |
|---|---|
Koncový bod webhooku (/v1/developer/webhook-endpoints) | Tajný klíč secret pro každý koncový bod (48 hexadecimálních znaků), vrácený jednou při vytvoření |
| Starší webhook s jedinou adresou URL | Tajný klíč secret pro organizaci vrácený při GET /v1/webhook |
Volání koncového bodu nástroje (přímé volání vašeho endpoint.url) | Tajný klíč webhooku na úrovni organizace (stejný jako pro starší webhook s jedinou adresou URL) — ne tajný klíč pro jednotlivý koncový bod |
Tajný klíč uložte do správce tajných klíčů nebo proměnné prostředí — nikdy jej neukládejte do repozitáře.
Ověřování volání nástrojů
Když hlasový agent přímo vyvolá jeden z vašich
funkčních nástrojů (nástroj má
endpoint), požadavek kromě vámi nakonfigurovaných endpoint.headers
obsahuje tyto hlavičky ThunderPhone:
X-ThunderPhone-Call-ID— číselné ID probíhajícího hovoru.X-ThunderPhone-Signature-V2— výše uvedený časově označený podpis, podepsaný vaším tajemstvím webhooku na úrovni organizace.X-ThunderPhone-Signature— starší podpis pouze těla se stejným tajemstvím.
Oproti webhookům se liší dvě věci:
- Nástroje
GET/DELETEnemají tělo. Argumenty se předávají jako parametry dotazu. Podpis V2 je zahrnuje prostřednictvím cesty a dotazu a pole těla je prázdné. Ověřujte pomocí nezpracovaného řetězce dotazu: parsování parametrů a vytvoření nového řetězce dotazu může změnit jejich pořadí nebo kódování. Starší hlavička podepisuje prázdný bajtový řetězec. - Organizace bez nakonfigurovaného staršího webhooku nemají tajemství organizace. V
takovém případě volání nástrojů obsahují pouze
X-ThunderPhone-Call-IDa žádné hlavičky podpisu. Nakonfigurujte starší webhook (PUT /v1/webhook) pro získání tajemství k podepisování nebo ověřujte volání nástrojů vlastní hlavičkou prostřednictvímendpoint.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)
...V Expressu parser těla ponechá req.body u požadavku GET jako prázdný objekt,
proto předejte Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Odesílání nástrojů v režimu webhooku (nástroje bez endpoint, doručované
na webhook vaší organizace jako telephony.tool / web.tool) je běžný
podepsaný webhook — platí pro něj standardní postup uvedený výše. Oba tvary požadavků najdete v části
Funkční nástroje.
Starší podpis: X-ThunderPhone-Signature
Původní hlavička obsahuje hexadecimální HMAC-SHA256 pouze nezpracovaného těla se stejným tajemstvím. Neobsahuje časové razítko, takže kdokoli, kdo zachytí jeden podepsaný požadavek, jej může znovu přehrát. Během přechodu integrací na V2 se stále odesílá s každým požadavkem.
- Přečtěte nezpracované tělo požadavku — přesné bajty, které jsme vám odeslali.
- Vypočítejte
hmac_sha256(secret, body).hexdigest(). - Porovnejte v konstantním čase s
X-ThunderPhone-Signature.
Podepisujeme přesně ty bajty, které přenášíme. Tyto bajty jsou také kanonickou serializací JSON datové části — klíče seřazené abecedně, kompaktní oddělovače (, a : bez mezer), UTF-8. Pokud váš framework zpřístupňuje pouze parsovaný JSON, znovu jej kanonicky serializujte a vypočítejte HMAC z něj:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Upřednostněte nezpracované tělo — je to o krok méně a vyhnete se zvláštnostem při opakované konverzi čísel JSON v některých jazycích. Totéž platí pro pole těla ve 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)
endBěžné chyby
Posun hodin
Pětiminutové okno se měří podle hodin vašeho serveru. Udržujte je synchronizované pomocí NTP; hodiny posunuté o více než 5 minut odmítnou každý požadavek.
Proxy přepisuje cestu
Podepsaná cesta je ta, kterou jsme požadovali: cesta adresy URL, kterou jste nakonfigurovali v ThunderPhone. Pokud reverzní proxy nebo brána API předtím, než vaše aplikace požadavek uvidí, odebere nebo přidá předponu, před ověřením znovu sestavte cestu, kterou jsme odeslali (například předponu vraťte zpět).
Opětovná serializace s výchozím formátováním
Parsování těla a jeho opětovný výpis s výchozím nastavením vaší knihovny JSON
(mezery po , / :, klíče v pořadí vložení) vytváří jiné bajty a poruší
HMAC. Ověřte nezpracované tělo — nebo pokud jej musíte znovu serializovat,
přesně dodržte náš kanonický formát: seřazené klíče, kompaktní oddělovače,
UTF-8.
Framework automaticky parsuje JSON
Middleware express.json() v Expressu spotřebuje datový proud těla
a přijdete o nezpracované bajty. Použijte express.raw() přímo pro cestu webhooku
nebo ukládejte nezpracované tělo do vyrovnávací paměti v předchozím middleware.
Totéž platí pro NestJS / Koa — podívejte se do jejich dokumentace k „raw body“.
Porovnání nebezpečné z hlediska časování
expected === signature v JS nebo expected == signature v
Pythonu jsou porovnání závislá na časování. Použijte příslušně crypto.timingSafeEqual
nebo hmac.compare_digest. Rozdíl ve výkonu
je nulový.
Nesprávný tajný klíč pro koncové body nástrojů
Přímá volání koncových bodů nástrojů jsou podepsána tajným klíčem webhooku
na úrovni organizace (GET /v1/webhook) — nikoli tajným klíčem pro konkrétní
koncový bod z /v1/developer/webhook-endpoints. Znovu použijte stejnou pomocnou
funkci, ale ujistěte se, že jí pro cesty nástrojů předáváte tajný klíč organizace.
Řetězce dotazu u nástrojů GET/DELETE
V2 podepisuje řetězec dotazu přesně v odeslané podobě. Starší hlavička podepisuje prázdné tělo a nikdy ne řetězec dotazu, takže hashování adresy URL pro starší hlavičku nikdy nebude odpovídat.
Duplikáty v rámci okna
Časové razítko zastaví opakování starší než 5 minut, nikoli druhou kopii v rámci
tohoto okna. Doručování na koncový bod probíhá alespoň jednou, takže může dorazit
i opakovaný pokus o stejnou událost; odstraňujte duplicity podle event_id
obálky.
Nevracení 401 při neshodě
Vrácení 200 při neúspěšném ověření dělá z handleru cíl pro opakované útoky. Pokud ověření selže, vždy odpovězte stavem mimo 2xx.