Open in
Preverjanje podpisov webhookov
Vsak webhook in zahteva orodja, ki ju ThunderPhone pošlje, sta podpisana s časovnim žigom. Preverite ju s tukajšnjim postopkom ali pomočnikom SDK ter zavrnite ponovljene zahteve, starejše od petih minut.
Vsaka zahteva, ki jo pošljemo na vaš strežnik (dostave webhookov, podedovani webhook z enim URL-jem in klici končnih točk orodij), vsebuje dve glavi podpisov. Obe uporabljata HMAC-SHA256 z isto skrivnostjo.
| Glava | Podpisuje | Zaščita pred ponovitvijo |
|---|---|---|
X-ThunderPhone-Signature-V2 (priporočeno) | Časovni žig, metodo, pot, poizvedbo in telo | Zavrnite zahteve, starejše od 5 minut |
X-ThunderPhone-Signature | Samo telo | Brez: prestrežena zahteva se preverja za vedno |
V novih integracijah preverjajte X-ThunderPhone-Signature-V2 in nanj
preselite obstoječe integracije. X-ThunderPhone-Signature se še vedno pošilja z vsako
zahtevo nespremenjen, zato preverjevalniki, zgrajeni na njem, še naprej delujejo. Preden ga prenehamo pošiljati, bomo objavili datum.
Podpis V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etje čas Unix v sekundah, ko smo poslali zahtevo. Ponovno poslana dostava se ob pošiljanju znova podpiše z lastnimt.v1je šestnajstiški HMAC-SHA256 spodnjega podpisanega niza, ustvarjen z vašim skrivnim ključem.
Vrednost je z vejicami ločen seznam parov key=value. Prezrite ključe, ki jih
ne prepoznate, in sprejmite zahtevo, če se ujema kateri koli v1: med prihodnjo
spremembo lahko dodamo drugi podpis.
Podpisani niz
Štiri polja, vsakemu sledi ena nova vrstica (\n, bajt 0x0A), razen
zadnjemu:
<t>\n<METHOD>\n<path and query>\n<body>| Polje | Vrednost |
|---|---|
t | Vrednost t iz glave |
METHOD | Metoda HTTP z velikimi črkami: POST, GET, PUT, PATCH ali DELETE |
| pot in poizvedba | Cilj zahteve iz vrstice zahteve HTTP: pot, nato ? in niz poizvedbe, kadar obstaja. Brez sheme, gostitelja ali fragmenta. Uporabite ga natanko tako, kot je prispel, brez dekodiranja ali spreminjanja vrstnega reda parametrov |
body | Neobdelano telo zahteve. Klici orodij GET in DELETE nimajo telesa, zato se njihov podpisani niz konča z novo vrstico za potjo |
Za preverjanje:
- Pred kakršnim koli razčlenjevanjem preberite glavo, metodo, neobdelano pot in poizvedbo ter neobdelano telo.
- Iz glave pridobite
tin vsakv1. - Zahtevo zavrnite, če je
tveč kot 300 sekund pred ali po vašem času. - S svojim skrivnim ključem izračunajte šestnajstiški HMAC-SHA256 podpisanega niza.
- Primerjajte ga z
v1v konstantnem času. Če se nič ne ujema, odgovorite z401.
Primer
Skrivni ključ whsec_example_4c1f2a9b. To zahtevo pošljemo ob
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"}Podpisani niz so te štiri vrstice brez nove vrstice za telesom:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Njegov HMAC-SHA256 je
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Preverite svojo implementacijo s tem ali ga ustvarite z openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Klic orodja GET na /tools/lookup?customer_id=cust_123 v isti
sekundi ima prazno telo. Njegov podpisani niz je
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, njegov podpis pa
je 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
S kompleti SDK
Kompleta SDK za Python in TypeScript vključujeta pomočnika
(thunderphone 0.2.0 ali novejši, @thunderphone/sdk 0.2.0 ali novejši). Preverja
5-minutno okno, primerja v konstantnem času in sprejme seznam skrivnih ključev,
da jih lahko zamenjate brez izpada.
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
});Brez SDK-ja
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)
);
});
}Povezava z ogrodjem
Vsak primer prebere neobdelano pot in poizvedbo ter neobdelano telo, nato pa pokliče pomožno funkcijo SDK-ja.
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) ter ASGI raw_path in query_string
(FastAPI) so ciljni naslov natanko takšni, kot so bili prejeti. Djangov
get_full_path() in Flaskov request.path najprej dekodirata pot, kar
za običajne poti ustvari enak niz. Če pot vaše končne točke vsebuje
znake s kodiranjem odstotkov ali rezervirane znake, kot so %2F, ; ali =, namesto tega preberite
neobdelani ciljni naslov, ki ga zagotavlja vaš strežnik (na primer request.META["RAW_URI"] v
Gunicornu).
Katera skrivnost?
Obe glavi sta podpisani z isto skrivnostjo.
| Vir | Skrivnost |
|---|---|
Končna točka webhooka (/v1/developer/webhook-endpoints) | secret za posamezno končno točko (48 šestnajstiških znakov), vrnjen enkrat ob ustvarjanju |
| Podedovani webhook z enim URL-jem | secret za posamezno organizacijo, vrnjen pri GET /v1/webhook |
Klic končne točke orodja (neposredni klic vaše endpoint.url) | Skrivnost webhooka na ravni organizacije (ista kot pri podedovanem webhooku z enim URL-jem) — ne skrivnost za posamezno končno točko |
Skrivnost shranite v upravitelju skrivnosti ali spremenljivki okolja — nikoli je ne potrdite v repozitorij.
Preverjanje klicev orodij
Ko agent neposredno prikliče eno od vaših
funkcijskih orodij (orodje ima endpoint), zahteva poleg
vaših konfiguriranih endpoint.headers vključuje te glave ThunderPhone:
X-ThunderPhone-Call-ID— številčni ID aktivnega klica.X-ThunderPhone-Signature-V2— zgornji podpis s časovnim žigom, podpisan z vašo skrivnostjo spletnega kavlja na ravni organizacije.X-ThunderPhone-Signature— zapuščeni podpis samo telesa z istim ključem.
Dve stvari se razlikujeta od spletnih kavljev:
- Orodja
GET/DELETEnimajo telesa. Argumenti se prenašajo kot parametri poizvedbe. Podpis V2 jih zajema prek poti in poizvedbe, polje telesa pa je prazno. Preverite z neobdelanim nizom poizvedbe: razčlenjevanje parametrov in ustvarjanje novega niza poizvedbe lahko spremeni njihov vrstni red ali kodiranje. Zapuščena glava podpiše prazen niz bajtov. - Organizacije brez konfiguriranega zapuščenega spletnega kavlja nimajo skrivnosti organizacije. V
tem primeru klici orodij vsebujejo le
X-ThunderPhone-Call-IDin nobene glave podpisa. Konfigurirajte zapuščeni spletni kavelj (PUT /v1/webhook), da pridobite skrivnost za podpisovanje, ali preverite pristnost klicev orodij z lastno glavo prekendpoint.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 razčlenjevalnik telesa pri GET pusti req.body kot prazen objekt,
zato podajte Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Usmerjanje orodij v načinu webhooka (orodja brez endpoint, dostavljena
na webhook vaše organizacije kot telephony.tool / web.tool) je običajen
podpisan webhook — uporabite zgornji standardni postopek. Za oba formata zahtevkov glejte
Funkcijska orodja.
Podedovani podpis: X-ThunderPhone-Signature
Izvirna glava je šestnajstiški HMAC-SHA256 samo neobdelanega telesa z istim skrivnim ključem. Ne vsebuje časovnega žiga, zato lahko vsakdo, ki prestreže eno podpisano zahtevo, to ponovno predvaja. Med prehodom integracij na V2 se še vedno pošilja ob vsaki zahtevi.
- Preberite neobdelano telo zahteve — natančne bajte, ki smo vam jih poslali.
- Izračunajte
hmac_sha256(secret, body).hexdigest(). - Primerjajte v konstantnem času z
X-ThunderPhone-Signature.
Podpišemo natančno tiste bajte, ki jih prenesemo. Ti bajti so tudi
kanonična serializacija JSON koristnega tovora — ključi so razvrščeni
po abecedi, ločila so strnjena (, in : brez presledkov), kodiranje pa je UTF-8.
Če vaše ogrodje omogoča le razčlenjeni JSON, ga znova kanonično serializirajte in
nad njim izračunajte HMAC:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Raje uporabite neobdelano telo — to je en korak manj in je odporno na posebnosti ponovnega pretvarjanja števil JSON v nekaterih jezikih. Enako velja za polje telesa v 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)
endPogoste pasti
Urni zamik
5-minutno okno se meri glede na uro vašega strežnika. Ohranite jo sinhronizirano z NTP; ura, ki odstopa za več kot 5 minut, zavrne vsako zahtevo.
Posredniški strežnik prepiše pot
Podpisana pot je tista, ki smo jo zahtevali: pot URL-ja, ki ste ga konfigurirali v ThunderPhone. Če povratni posredniški strežnik ali prehod API odstrani ali doda predpono, preden vaša aplikacija vidi zahtevo, pred preverjanjem znova sestavite pot, ki smo jo poslali (na primer znova dodajte predpono).
Ponovno serializiranje s privzetim oblikovanjem
Razčlenjevanje telesa in njegovo ponovno izpisovanje s privzetimi nastavitvami
vaše knjižnice JSON (presledki za , / :, ključi v vrstnem redu vstavljanja) ustvari
različne bajte in prekine HMAC. Preverite surovo telo — ali če ga morate
znova serializirati, natančno uporabite našo kanonično obliko: razvrščeni
ključi, strnjena ločila, UTF-8.
Ogrodje samodejno razčleni JSON
Vmesna programska oprema express.json() v Expressu porabi tok telesa
in izgubite neobdelane bajte. Posebej za pot webhooka uporabite express.raw()
ali shranite neobdelano telo v predhodni vmesni programski opremi.
Enako velja za NestJS / Koa — preverite njihovo dokumentacijo za »raw body«.
Primerjava, nevarna glede časa izvajanja
expected === signature v JS ali expected == signature v
Pythonu sta primerjavi s spremenljivim časom izvajanja. Uporabite crypto.timingSafeEqual
oziroma hmac.compare_digest. Razlike v zmogljivosti ni.
Napačna skrivnost za končne točke orodij
Neposredni klici končnih točk orodij so podpisani z skrivnostjo webhooka
na ravni organizacije (GET /v1/webhook) — ne s katero koli skrivnostjo posamezne končne točke iz
/v1/developer/webhook-endpoints. Ponovno uporabite istega pomočnika,
vendar poskrbite, da mu na poteh orodij posredujete skrivnost organizacije.
Poizvedbeni nizi pri orodjih GET/DELETE
V2 podpiše poizvedbeni niz natanko tako, kot je bil poslan. Podedovana glava podpiše prazno telo in nikoli poizvedbenega niza, zato se zgoščevanje URL-ja za podedovano glavo nikoli ne bo ujemalo.
Dvojniki znotraj okna
Časovni žig prepreči ponovitve, starejše od 5 minut, ne pa druge kopije
znotraj tega okna. Dostave na končne točke so vsaj enkratne, zato lahko prispe tudi
ponovni poskus istega dogodka; odstranite podvojitve na podlagi vrednosti event_id
ovojnice.
Nevračanje odgovora 401 ob neujemanju
Vračanje 200 ob neuspelem preverjanju spremeni upravljalnik v cilj za ponovitvene napade. Če preverjanje ne uspe, vedno odgovorite s kodo, ki ni 2xx.