Open in
Verificați semnăturile webhook
Fiecare cerere webhook și de instrument trimisă de ThunderPhone este semnată cu un marcaj temporal. Verificați-o cu rețeta sau cu ajutorul SDK de aici și respingeți cererile retransmise mai vechi de cinci minute.
Fiecare cerere pe care o trimitem către serverul dumneavoastră (livrări webhook, webhook-ul vechi cu un singur URL și apeluri către endpoint-uri de instrumente) include două antete de semnătură. Ambele folosesc HMAC-SHA256 cu același secret.
| Antet | Semnează | Protecție împotriva reluării |
|---|---|---|
X-ThunderPhone-Signature-V2 (recomandat) | Marcaj temporal, metodă, cale, interogare și corp | Respingeți cererile mai vechi de 5 minute |
X-ThunderPhone-Signature | Doar corpul | Niciuna: o cerere capturată se verifică pentru totdeauna |
Verificați X-ThunderPhone-Signature-V2 în integrările noi și migrați
integrările existente către acesta. X-ThunderPhone-Signature este încă trimis la fiecare
cerere, neschimbat, astfel încât verificatorii bazați pe acesta continuă să funcționeze. Vom
anunța o dată înainte de a înceta să îl mai trimitem.
Semnătura V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4eteste timpul Unix, în secunde, la care am trimis cererea. O livrare reîncercată este semnată din nou când este trimisă, cu propriult.v1este HMAC-SHA256 hexazecimal al șirului semnat de mai jos, calculat cu secretul dvs.
Valoarea este o listă de perechi key=value separate prin virgule. Ignorați cheile pe care
nu le recunoașteți și acceptați cererea dacă se potrivește orice v1: este posibil să adăugăm
o a doua semnătură într-o modificare viitoare.
Șirul semnat
Patru câmpuri, fiecare urmat de un caracter de linie nouă (\n, octetul 0x0A), cu excepția
ultimului:
<t>\n<METHOD>\n<path and query>\n<body>| Câmp | Valoare |
|---|---|
t | Valoarea t din antet |
METHOD | Metoda HTTP cu majuscule: POST, GET, PUT, PATCH sau DELETE |
| path and query | Ținta cererii din linia cererii HTTP: calea, apoi ? și șirul de interogare, dacă există. Fără schemă, gazdă sau fragment. Folosiți-o exact cum a fost primită, fără a o decoda sau a reordona parametrii |
body | Corpul brut al cererii. Apelurile de instrumente GET și DELETE nu au corp, astfel încât șirul lor semnat se termină cu caracterul de linie nouă de după cale |
Pentru verificare:
- Înainte de orice analiză, citiți antetul, metoda, calea brută și interogarea, precum și corpul brut.
- Preluați
tși fiecarev1din antet. - Respingeți cererea dacă
teste cu mai mult de 300 de secunde înainte sau după ceasul dvs. - Calculați HMAC-SHA256 hexazecimal al șirului semnat cu secretul dvs.
- Comparați-l cu
v1în timp constant. Răspundeți cu401dacă nu se potrivește nimic.
Exemplu complet
Secretul este whsec_example_4c1f2a9b. Trimitem această cerere la
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"}Șirul semnat este format din aceste patru linii, fără caracter de linie nouă după corp:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}HMAC-SHA256 al acestuia este
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Verificați implementarea dvs. comparând-o cu acesta sau reproduceți-l cu openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Un apel de instrument GET către /tools/lookup?customer_id=cust_123 în aceeași
secundă are corpul gol. Șirul său semnat este
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, iar semnătura sa
este 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Cu SDK-urile
SDK-urile Python și TypeScript includ un ajutor
(thunderphone 0.2.0 sau o versiune ulterioară, @thunderphone/sdk 0.2.0 sau o versiune ulterioară). Acesta
verifică fereastra de 5 minute, compară în timp constant și acceptă o listă
de secrete, astfel încât să le puteți roti fără întreruperi.
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
});Fără un 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)
);
});
}Conectarea frameworkului
Fiecare exemplu citește calea și interogarea brute, precum și corpul brut, apoi apelează funcția de ajutor din 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), precum și raw_path și query_string din ASGI
(FastAPI) reprezintă ținta exact așa cum a fost primită. get_full_path() din Django și
request.path din Flask decodifică mai întâi calea, ceea ce
produce același șir pentru căile obișnuite. Dacă calea endpointului dumneavoastră conține
caractere codificate procentual sau rezervate, precum %2F, ; sau =, citiți în schimb
ținta brută furnizată de serverul dumneavoastră (de exemplu request.META["RAW_URI"] pe
Gunicorn).
Ce secret?
Același secret semnează ambele anteturi.
| Sursă | Secret |
|---|---|
Endpoint webhook (/v1/developer/webhook-endpoints) | secret per endpoint (48 de caractere hexazecimale) returnat o singură dată la creare |
| Webhook moștenit cu un singur URL | secret per organizație returnat la GET /v1/webhook |
Apelare endpoint de instrumente (apel direct către endpoint.url al dumneavoastră) | Secretul webhook la nivel de organizație (același ca pentru webhookul moștenit cu un singur URL) — nu un secret per endpoint |
Stocați secretul în managerul dumneavoastră de secrete sau într-o variabilă de mediu — nu îl comiteți niciodată.
Verificarea apelurilor de instrumente
Când agentul invocă direct unul dintre
instrumentele dumneavoastră de funcție (instrumentul are un
endpoint), cererea include aceste antete ThunderPhone pe lângă
endpoint.headers configurat:
X-ThunderPhone-Call-ID— ID-ul numeric al apelului activ.X-ThunderPhone-Signature-V2— semnătura cu marcaj temporal de mai sus, asociată cu secretul webhookului la nivel de organizație.X-ThunderPhone-Signature— semnătura veche doar pentru corp, cu aceeași cheie.
Două aspecte diferă față de webhookuri:
- Instrumentele
GET/DELETEnu au corp. Argumentele sunt transmise ca parametri de interogare. Semnătura V2 le acoperă prin cale și interogare, iar câmpul corpului este gol. Verificați folosind șirul de interogare brut: analizarea parametrilor și construirea unui nou șir de interogare le poate modifica ordinea sau codificarea. Antetul vechi semnează șirul de octeți gol. - Organizațiile fără un webhook vechi configurat nu au un secret de organizație. În
acest caz, apelurile instrumentelor includ doar
X-ThunderPhone-Call-IDși niciun antet de semnătură. Configurați webhookul vechi (PUT /v1/webhook) pentru a obține un secret de semnare sau autentificați apelurile instrumentelor cu propriul antet prinendpoint.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)
...În Express, un parser de corp lasă req.body ca obiect gol pentru un GET,
așadar transmiteți Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Expedierea instrumentelor în modul webhook (instrumente fără un endpoint, livrate
către webhook-ul organizației dumneavoastră ca telephony.tool / web.tool) este un webhook semnat obișnuit — se aplică rețeta standard de mai sus. Consultați
Instrumente de funcții pentru ambele forme de solicitare.
Semnătură moștenită: X-ThunderPhone-Signature
Antetul original este HMAC-SHA256 în format hex al corpului brut, folosind același secret. Nu conține niciun marcaj temporal, astfel încât oricine capturează o cerere semnată o poate retransmite. Este trimis în continuare la fiecare cerere cât timp integrările migrează la V2.
- Citiți corpul de cerere brut — octeții exacți pe care vi i-am trimis.
- Calculați
hmac_sha256(secret, body).hexdigest(). - Comparați în timp constant cu
X-ThunderPhone-Signature.
Semnăm exact octeții pe care îi transmitem. Acești octeți reprezintă și
serializarea JSON canonică a încărcăturii utile — chei sortate
alfabetic, separatori compacți (, și : fără spații), UTF-8.
Dacă frameworkul dumneavoastră expune doar JSON-ul analizat, reserializați canonic și
calculați HMAC pentru acesta:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Preferați corpul brut — este cu un pas mai puțin și este imun la particularitățile de conversie dus-întors ale numerelor JSON în unele limbaje. Același lucru este valabil pentru câmpul de corp din 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)
endCapcane frecvente
Decalajul ceasului
Fereastra de 5 minute este măsurată în raport cu ceasul serverului dumneavoastră. Mențineți-l sincronizat cu NTP; un ceas decalibrat cu mai mult de 5 minute respinge fiecare cerere.
Un proxy rescrie calea
Calea semnată este cea pe care am solicitat-o: calea URL-ului pe care l-ați configurat în ThunderPhone. Dacă un proxy invers sau un gateway API elimină sau adaugă un prefix înainte ca aplicația dumneavoastră să vadă cererea, reconstruiți calea pe care am trimis-o (de exemplu, adăugați prefixul înapoi) înainte de verificare.
Reserializarea cu formatarea implicită
Analizarea corpului și serializarea lui din nou folosind setările
implicite ale bibliotecii JSON (spații după , / :, chei în ordinea inserării) produce
octeți diferiți și invalidează HMAC-ul. Verificați corpul brut — sau, dacă
trebuie să îl reserializați, reproduceți exact forma noastră canonică: chei
sortate, separatori compacți, UTF-8.
Frameworkul analizează automat JSON-ul
Middleware-ul express.json() din Express consumă fluxul corpului
și pierdeți octeții bruți. Utilizați express.raw() în mod specific pe ruta
webhookului sau stocați în buffer corpul brut într-un pre-middleware.
La fel și pentru NestJS / Koa — consultați documentația lor despre „corpul brut”.
Comparație nesigură din perspectiva timpului
expected === signature în JS sau expected == signature în
Python sunt comparații cu durată variabilă. Utilizați crypto.timingSafeEqual
sau hmac.compare_digest, respectiv. Diferența de performanță
este nulă.
Secret greșit pentru endpointurile instrumentelor
Apelurile directe către endpointurile instrumentelor sunt semnate cu secretul webhook
la nivel de organizație (GET /v1/webhook) — nu cu vreun secret per-endpoint
din /v1/developer/webhook-endpoints. Reutilizați același ajutor,
dar asigurați-vă că îi furnizați secretul organizației pentru rutele instrumentelor.
Șiruri de interogare pentru instrumentele GET/DELETE
V2 semnează șirul de interogare exact așa cum a fost trimis. Antetul vechi semnează corpul gol și niciodată șirul de interogare, astfel încât hash-ul URL-ului pentru antetul vechi nu va corespunde niciodată.
Duplicate în interiorul ferestrei
Marca temporală oprește retransmiterile mai vechi de 5 minute, nu o a doua copie
din interiorul acelei ferestre. Livrările către endpointuri sunt efectuate cel puțin o dată, astfel încât
poate ajunge și o reîncercare a aceluiași eveniment; eliminați duplicatele folosind
event_id din plic.
Nereturnarea codului 401 la nepotrivire
Returnarea codului 200 când verificarea eșuează transformă handlerul într-o țintă pentru reluări. Răspundeți întotdeauna cu un cod care nu este 2xx dacă verificarea eșuează.
Pașii următori
Semantica livrării, reîncercări, IP-uri sursă.
Gestionați mai multe URL-uri, rotiți secretele.
Cele două căi de invocare a instrumentelor și formatele cererilor acestora.
Construiți cap-coadă o integrare completă bazată pe instrumente.