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 |
| путь и строка запроса | Цель запроса из строки 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) содержат целевой адрес точно в том виде, в котором он был получен. get_full_path() в Django и request.path во Flask сначала декодируют путь, поэтому для обычных путей возвращается та же строка. Если путь вашей конечной точки содержит символы с процентным кодированием или зарезервированные символы, например %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-адресами, меняйте секреты.
Два пути вызова инструментов и структура их запросов.
Создайте полноценную интеграцию с поддержкой инструментов от начала до конца.