Open in
Проверите потписе вебхукова
Сваки захтев вебхука и алатке који ThunderPhone шаље потписан је временском ознаком. Проверите га помоћу овде наведеног примера или SDK помоћне функције и одбијте поновљене захтеве старије од пет минута.
Сваки захтев који шаљемо на Ваш сервер (испоруке веб-хука, застарели веб-хук са једним URL-ом и позиви крајњих тачака алатки) садржи два заглавља потписа. Оба користе HMAC-SHA256 са истом тајном.
| Заглавље | Потписује | Заштита од поновљеног слања |
|---|---|---|
X-ThunderPhone-Signature-V2 (препоручено) | Временска ознака, метод, путања, упит и тело | Одбијте захтеве старије од 5 минута |
X-ThunderPhone-Signature | Само тело | Нема: пресретнути захтев се може верификовати заувек |
Верификујте X-ThunderPhone-Signature-V2 у новим интеграцијама и
преместите постојеће на њега. X-ThunderPhone-Signature се и даље шаље уз сваки
захтев, непромењен, тако да верификатори изграђени на њему настављају да раде. Објавићемо датум пре него што престанемо да га шаљемо.
Потпис V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etје Unix време, у секундама, када смо послали захтев. Поновљена испорука се поново потписује приликом слања, са сопственимt.v1је хексадецимални HMAC-SHA256 потписаног низа испод, са Вашом тајном као кључем.
Вредност је листа парова key=value раздвојених зарезима. Занемарите кључеве
које не препознајете и прихватите захтев ако се било који v1 подудара: можемо
додати други потпис током будуће измене.
Потписани низ
Четири поља, од којих свако прати један нови ред (\n, бајт 0x0A), осим
последњег:
<t>\n<METHOD>\n<path and query>\n<body>| Поље | Вредност |
|---|---|
t | Вредност t из заглавља |
METHOD | HTTP метод великим словима: POST, GET, PUT, PATCH или DELETE |
| path and query | Циљ захтева из HTTP линије захтева: путања, затим ? и ниска упита када постоји. Без шеме, хоста или фрагмента. Користите га тачно онако како је примљен, без декодирања или промене редоследа параметара |
body | Сирово тело захтева. Позиви алата GET и DELETE немају тело, па се њихов потписани низ завршава новим редом после путање |
За проверу:
- Пре било каквог рашчлањивања, прочитајте заглавље, метод, сирову путању и упит, као и сирово тело.
- Преузмите
tи свакиv1из заглавља. - Одбијте захтев ако је
tвише од 300 секунди пре или после Вашег сата. - Израчунајте хексадецимални HMAC-SHA256 потписаног низа са Вашом тајном.
- Упоредите га са
v1у константном времену. Одговорите са401ако се ништа не подудара.
Пример
Тајна whsec_example_4c1f2a9b. Овај захтев шаљемо у
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"}Потписани низ су ова четири реда, без новог реда после тела:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Његов HMAC-SHA256 је
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Проверите своју имплементацију према њему или га репродукујте помоћу openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Позив алата GET на /tools/lookup?customer_id=cust_123 у истој
секунди има празно тело. Његов потписани низ је
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, а његов потпис
је 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Са SDK-овима
Python и TypeScript SDK-ови укључују помоћну функцију
(thunderphone 0.2.0 или новији, @thunderphone/sdk 0.2.0 или новији). Она
проверава прозор од 5 минута, пореди у константном времену и прихвата листу
тајни како бисте могли да их ротирате без прекида рада.
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
});Без 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)
);
});
}Повезивање оквира
Сваки пример чита сирову путању и упит, као и сирово тело, а затим позива 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) и ASGI raw_path и query_string
(FastAPI) представљају циљ тачно онако како је примљен. Django-ови
get_full_path() и Flask-ов request.path најпре декодирају путању, што
даје исти ниска знакова за уобичајене путање. Ако путања вашег крајњег одредишта садржи
процентуално кодиране или резервисане знакове као што су %2F, ; или =, уместо тога прочитајте
сирови циљ који ваш сервер пружа (на пример request.META["RAW_URI"] на
Gunicorn-у).
Која тајна?
Иста тајна потписује оба заглавља.
| Извор | Тајна |
|---|---|
Webhook крајња тачка (/v1/developer/webhook-endpoints) | secret за сваку крајњу тачку (48 хексадецималних знакова) који се враћа једном при креирању |
| Застарели webhook са једним URL-ом | secret за сваку организацију који се враћа при GET /v1/webhook |
Позив крајње тачке алата (директан позив вашег endpoint.url) | Webhook тајна на нивоу организације (иста као за застарели webhook са једним URL-ом) — не тајна за појединачну крајњу тачку |
Чувајте тајну у менаџеру тајни или променљивој окружења — никада је не предајте у репозиторијум.
Верификација позива алата
Када гласовни агент директно позове један од ваших
функционалних алата (алат има
endpoint), захтев садржи ова ThunderPhone заглавља уз
ваша конфигурисана endpoint.headers:
X-ThunderPhone-Call-ID— нумерички ИД активног позива.X-ThunderPhone-Signature-V2— горенаведени потпис са временском ознаком, потписан вашом тајном вредношћу веб-куке на нивоу организације.X-ThunderPhone-Signature— застарели потпис само за тело, са истим кључем.
Две ствари се разликују у односу на веб-куке:
GET/DELETEалати немају тело захтева. Аргументи се прослеђују као параметри упита. Потпис V2 их обухвата путем путање и упита, а поље тела је празно. Проверите помоћу сировог низа упита: рашчлањивање параметара и прављење новог низа упита може променити њихов редослед или кодирање. Застарело заглавље потписује празан низ бајтова.- Организације без конфигурисане застареле веб-куке немају тајну вредност организације. У
том случају позиви алата садрже само
X-ThunderPhone-Call-IDи немају заглавља потписа. Конфигуришите застарелу веб-куку (PUT /v1/webhook) да бисте добили тајну вредност за потписивање или аутентификујте позиве алата сопственим заглављем прекоendpoint.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)
...У Express-у, рашчлањивач тела оставља req.body као празан објекат за GET,
па проследите Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Отпрема алата у режиму веб-хука (алати без endpoint, испоручени
на веб-хук ваше организације као telephony.tool / web.tool) је
обичан потписани веб-хук — важи стандардни поступак изнад. Погледајте
Алати функција за оба облика захтева.
Застарели потпис: X-ThunderPhone-Signature
Оригинално заглавље је хексадецимални HMAC-SHA256 само сировог тела, са истом тајном. Не садржи временску ознаку, тако да свако ко пресретне један потписани захтев може поново да га пошаље. И даље се шаље уз сваки захтев док интеграције прелазе на V2.
- Прочитајте сирово тело захтева — тачне бајтове које смо Вам послали.
- Израчунајте
hmac_sha256(secret, body).hexdigest(). - Упоредите у константном времену са
X-ThunderPhone-Signature.
Потписујемо тачно бајтове које преносимо. Ти бајтови су такође
канонска JSON серијализација корисног садржаја — кључеви поређани
абецедним редом, компактни раздвајачи (, и : без размака), UTF-8.
Ако Ваш оквир приказује само рашчлањени JSON, поново га канонски серијализујте и
израчунајте HMAC над њим:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Дајте предност сировом телу — то је један корак мање и отпорно је на особености поновног претварања JSON бројева у неким језицима. Исто важи за поље тела у 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)
endУобичајене замке
Одступање часовника
Петоминутни период мери се у односу на часовник вашег сервера. Одржавајте га синхронизованим са NTP-ом; часовник који одступа више од 5 минута одбија сваки захтев.
Прокси мења путању
Потписана путања је она коју смо захтевали: путања URL-а који сте конфигурисали у ThunderPhone. Ако обрнути прокси или API мрежни пролаз уклони или дода префикс пре него што ваша апликација прими захтев, поново саставите путању коју смо послали (на пример, вратите префикс) пре верификације.
Поновна серијализација са подразумеваним форматирањем
Рашчлањивање тела и његово поновно серијализовање са подразумеваним поставкама
ваше JSON библиотеке (размаци после , / :, кључеви у редоследу уметања) производи
другачије бајтове и нарушава HMAC. Проверите сирово тело — или, ако
морате поново да га серијализујете, тачно га ускладите са нашим канонским обликом:
сортирани кључеви, сажети раздвајачи, UTF-8.
Оквир аутоматски парсира JSON
Express-ов express.json() посреднички софтвер троши ток тела
и губите сирове бајтове. Користите express.raw() посебно на webhook
рути или баферујте сирово тело у претходном посредничком софтверу.
Исто важи за NestJS / Koa — погледајте њихову документацију о „сировом телу“.
Поређење небезбедно у погледу времена
expected === signature у JS или expected == signature у
Python-у су поређења променљивог трајања. Користите crypto.timingSafeEqual
односно hmac.compare_digest. Разлика у перформансама
је занемарљива.
Погрешна тајна за крајње тачке алатки
Директни позиви крајњих тачака алатки потписују се помоћу тајне веб-хука
на нивоу организације (GET /v1/webhook) — не помоћу тајне по крајњој тачки
из /v1/developer/webhook-endpoints. Поново употребите исту помоћну функцију,
али уверите се да јој прослеђујете тајну организације на путањама алатки.
Параметри упита у GET/DELETE алаткама
V2 потписује параметре упита тачно онако како су послати. Застарело заглавље потписује празно тело, а никада параметре упита, па се хеширање URL-а за застарело заглавље никада неће подударати.
Дупликати унутар периода
Временска ознака спречава понављања старија од 5 минута, а не другу копију
унутар тог периода. Испоруке на крајње тачке су најмање једном, па може
стићи и поновни покушај истог догађаја; дедуплицирајте према
event_id омотача.
Невраћање 401 при неподударности
Враћање 200 при неуспешној верификацији чини обрађивач метом за поновљене захтеве. Увек одговорите статусом који није 2xx ако верификација не успе.