Проверка подписей вебхуков
Каждый вебхук и запрос к инструменту, отправляемый ThunderPhone, подписывается. Один раз проверьте подпись по приведённой здесь инструкции, затем используйте ту же проверку для каждой конечной точки, которую вы запускаете.
Каждый запрос, который мы отправляем на ваш сервер — доставки вебхуков и
вызовы конечных точек инструментов — содержит подпись HMAC-SHA256 в заголовке
X-ThunderPhone-Signature. Один раз правильно настройте проверку и
подключите тот же помощник к каждому обработчику.
Алгоритм
- Прочитайте исходное тело запроса — точные байты, которые мы отправили вам методом POST.
- Вычислите
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 в некоторых языках.
Какой секрет?
| Источник | Секрет |
|---|---|
Конечная точка вебхука (/v1/developer/webhook-endpoints) | secret для конкретной конечной точки (48 шестнадцатеричных символов), возвращается один раз при создании |
| Устаревший вебхук с одним URL | secret для организации, возвращаемый при GET /v1/webhook |
Вызов конечной точки инструмента (прямой вызов вашего endpoint.url) | Секрет вебхука уровня организации (тот же, что и для устаревшего вебхука с одним URL) — не секрет конкретной конечной точки |
Храните секрет в менеджере секретов или переменной окружения — никогда не добавляйте его в коммит.
Эталонные реализации
Все четыре проверяют исходное тело запроса:
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Интеграция для конкретных фреймворков
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
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 sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
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 JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})Проверка вызовов инструментов
Когда агент напрямую вызывает один из ваших
функциональных инструментов (у инструмента есть
endpoint), запрос содержит два заголовка ThunderPhone наряду с
настроенными вами endpoint.headers:
X-ThunderPhone-Call-ID— числовой идентификатор текущего звонка.X-ThunderPhone-Signature— HMAC-SHA256 с ключом в виде вашего секрета вебхука на уровне организации, вычисленный по точным байтам тела запроса.
Тот же помощник verify() работает без изменений, с двумя нюансами:
- У инструментов
GET/DELETEнет тела. Аргументы передаются как параметры запроса, а подпись вычисляется по пустой строке байтов — поэтому используйтеverify(b"", sig, secret)(Python) илиverify(Buffer.alloc(0), sig, secret)(Node). Не хешируйте строку запроса. - У организаций без настроенного устаревшего вебхука нет секрета организации. В
этом случае вызовы инструментов содержат только
X-ThunderPhone-Call-IDи не содержат заголовок подписи. Настройте устаревший вебхук (PUT /v1/webhook), чтобы получить секрет для подписи, или аутентифицируйте вызовы инструментов собственным заголовком черезendpoint.headers.
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...Диспетчеризация инструментов в режиме вебхука (инструменты без endpoint, доставляемые
в вебхук вашей организации как telephony.tool / web.tool) — это обычный
подписанный вебхук: применяется стандартный рецепт выше. Сведения об обеих
формах запросов см. в разделе Функциональные инструменты.
Распространённые ошибки
Повторная сериализация со стандартным форматированием
Разбор тела и его повторная сериализация с настройками по умолчанию вашей 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. Используйте ту же функцию verify(),
но убедитесь, что передаёте ей секрет организации для маршрутов инструментов.
Хеширование строки запроса для инструментов GET/DELETE
Для методов инструментов без тела подпись охватывает пустую строку байтов, сохраняя единый универсальный подход: вычисляйте HMAC для необработанного тела запроса, каким бы оно ни было. Хеш URL или строки запроса никогда не совпадёт.
Отсутствие ответа 401 при несовпадении
Возврат 200 при неудачной проверке делает обработчик целью для повторных атак. Всегда отвечайте кодом не из диапазона 2xx, если проверка не пройдена.
Следующие шаги
Семантика доставки, повторные попытки, исходные IP-адреса.
Управляйте несколькими URL-адресами, меняйте секреты.
Два пути вызова инструментов и структура их запросов.
Создайте полноценную интеграцию с поддержкой инструментов от начала до конца.