ThunderPhone 2.0 уже доступен.Самостоятельный запуск — от 2 ¢/мин.Читать анонс

Operations

Проверка подписей вебхуков

Каждый вебхук и запрос к инструменту, отправляемый 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t — это Unix-время в секундах, когда мы отправили запрос. При повторной доставке подпись создаётся заново в момент отправки, с собственным t.
  • v1 — это шестнадцатеричный HMAC-SHA256 подписанной строки ниже, вычисленный с использованием вашего секрета в качестве ключа.

Значение представляет собой список пар key=value, разделённых запятыми. Игнорируйте ключи, которые вы не распознаёте, и принимайте запрос, если совпадает любой v1: в будущем мы можем добавить вторую подпись при изменении.

Подписанная строка

Четыре поля, каждое из которых завершается одним переводом строки (\n, байт 0x0A), кроме последнего:

<t>\n<METHOD>\n<path and query>\n<body>
ПолеЗначение
tЗначение t из заголовка
METHODHTTP-метод в верхнем регистре: POST, GET, PUT, PATCH или DELETE
путь и строка запросаЦель запроса из строки HTTP-запроса: путь, затем ? и строка запроса, если она есть. Без схемы, хоста или фрагмента. Используйте её точно в полученном виде, не декодируя и не меняя порядок параметров
bodyНеобработанное тело запроса. Вызовы инструмента GET и DELETE не имеют тела, поэтому их подписанная строка заканчивается переводом строки после пути

Для проверки:

  1. До любого разбора прочитайте заголовок, метод, необработанные путь и строку запроса, а также необработанное тело.
  2. Извлеките t и все значения v1 из заголовка.
  3. Отклоните запрос, если t отличается от ваших часов более чем на 300 секунд в прошлое или будущее.
  4. Вычислите шестнадцатеричный HMAC-SHA256 подписанной строки, используя ваш секрет.
  5. Сравните его с 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-минутное окно, сравнивает за постоянное время и принимает список секретов, чтобы вы могли выполнять ротацию без простоя.

Python
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
)
Node.js
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

Python
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)
Node.js
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.

FastAPI
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}
Express
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);
  },
);
Django
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 шестнадцатеричных символов), возвращается один раз при создании
Устаревший вебхук с одним URLsecret для организации, возвращаемый при GET /v1/webhook
Вызов конечной точки инструмента (прямой вызов вашего endpoint.url)Секрет вебхука уровня организации (тот же, что и для устаревшего вебхука с одним URL) — не секрет конкретной конечной точки

Храните секрет в менеджере секретов или переменной окружения — никогда не добавляйте его в коммит.

Проверка вызовов инструментов

Когда агент напрямую вызывает один из ваших инструментов-функций (у инструмента есть endpoint), запрос содержит следующие заголовки ThunderPhone наряду с настроенными endpoint.headers:

  • X-ThunderPhone-Call-ID — числовой идентификатор текущего звонка.
  • X-ThunderPhone-Signature-V2 — указанная выше подпись с меткой времени, использующая ваш секрет вебхука уровня организации.
  • X-ThunderPhone-Signature — устаревшая подпись только тела, использующая тот же ключ.

От вебхуков есть два отличия:

  1. Инструменты GET / DELETE не имеют тела. Аргументы передаются как параметры запроса. Подпись V2 охватывает их через путь и параметры запроса, а поле тела остаётся пустым. Проверяйте с необработанной строкой запроса: разбор параметров и создание новой строки запроса могут изменить их порядок или кодировку. Устаревший заголовок подписывает пустую строку байтов.
  2. У организаций без настроенного устаревшего вебхука нет секрета организации. В этом случае вызовы инструментов содержат только 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.

  1. Прочитайте необработанное тело запроса — точные байты, которые мы вам отправили.
  2. Вычислите hmac_sha256(secret, body).hexdigest().
  3. Сравните с 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.

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
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, если проверка не пройдена.


Следующие шаги