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
SDK для Python і TypeScript містять допоміжну функцію
(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).
Який секрет?
Той самий секрет підписує обидва заголовки.
| Джерело | Секрет |
|---|---|
Кінцева точка вебхука (/v1/developer/webhook-endpoints) | secret для кожної кінцевої точки (48 шістнадцяткових символів), що повертається один раз під час створення |
| Застарілий вебхук з однією URL-адресою | secret для кожної організації, що повертається через GET /v1/webhook |
Виклик кінцевої точки інструменту (прямий виклик вашого endpoint.url) | Секрет вебхука на рівні організації (той самий, що й для застарілого вебхука з однією 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Поширені помилки
Розсинхронізація годинника
5-хвилинне вікно вимірюється за годинником вашого сервера. Синхронізуйте його з NTP; годинник, що відстає або поспішає більш ніж на 5 хвилин, відхилятиме кожен запит.
Проксі переписує шлях
Підписаний шлях — це той, який ми запитали: шлях URL-адреси, яку ви налаштували в ThunderPhone. Якщо зворотний проксі або шлюз API видаляє чи додає префікс до того, як ваша програма побачить запит, відновіть надісланий нами шлях (наприклад, поверніть префікс) перед перевіркою.
Повторна серіалізація з форматуванням за замовчуванням
Аналіз тіла та його повторне виведення з налаштуваннями за замовчуванням вашої бібліотеки JSON
(пробіли після , / :, ключі в порядку вставлення) створює
інші байти та порушує HMAC. Перевіряйте необроблене тіло — або, якщо
повторна серіалізація необхідна, точно дотримуйтеся нашої канонічної форми: відсортовані
ключі, компактні роздільники, UTF-8.
Фреймворк автоматично аналізує JSON
Проміжне ПЗ Express express.json() споживає потік тіла запиту,
і ви втрачаєте необроблені байти. Використовуйте express.raw() саме для маршруту
вебхука або буферизуйте необроблене тіло запиту в проміжному ПЗ, що виконується раніше.
Те саме стосується 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, якщо перевірка не пройшла.
Наступні кроки
Семантика доставки, повторні спроби, IP-адреси джерел.
Керуйте кількома URL-адресами, змінюйте секрети.
Два шляхи виклику інструментів і форми їхніх запитів.
Створіть повну інтеграцію на основі інструментів від початку до кінця.