ThunderPhone 2.0 уже доступен.Самостоятельное подключение — от 2 центов/мин.Читать анонс

Operations

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

Каждый вебхук и запрос к инструменту, отправляемый ThunderPhone, подписывается. Один раз проверьте подпись по приведённой здесь инструкции, затем используйте ту же проверку для каждой конечной точки, которую вы запускаете.

Каждый запрос, который мы отправляем на ваш сервер — доставки вебхуков и вызовы конечных точек инструментов — содержит подпись HMAC-SHA256 в заголовке X-ThunderPhone-Signature. Один раз правильно настройте проверку и подключите тот же помощник к каждому обработчику.

Алгоритм

  1. Прочитайте исходное тело запроса — точные байты, которые мы отправили вам методом POST.
  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 в некоторых языках.

Какой секрет?

ИсточникСекрет
Конечная точка вебхука (/v1/developer/webhook-endpoints)secret для конкретной конечной точки (48 шестнадцатеричных символов), возвращается один раз при создании
Устаревший вебхук с одним URLsecret для организации, возвращаемый при GET /v1/webhook
Вызов конечной точки инструмента (прямой вызов вашего endpoint.url)Секрет вебхука уровня организации (тот же, что и для устаревшего вебхука с одним URL) — не секрет конкретной конечной точки

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

Эталонные реализации

Все четыре проверяют исходное тело запроса:

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

Интеграция для конкретных фреймворков

FastAPI
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}
Express
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);
  },
);
Django
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() работает без изменений, с двумя нюансами:

  1. У инструментов GET / DELETE нет тела. Аргументы передаются как параметры запроса, а подпись вычисляется по пустой строке байтов — поэтому используйте verify(b"", sig, secret) (Python) или verify(Buffer.alloc(0), sig, secret) (Node). Не хешируйте строку запроса.
  2. У организаций без настроенного устаревшего вебхука нет секрета организации. В этом случае вызовы инструментов содержат только 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, если проверка не пройдена.


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