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 шістнадцяткових символів), що повертається один раз під час створення
Застарілий вебхук з однією URL-адресоюsecret для кожної організації, що повертається через 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, якщо перевірка не пройшла.


Наступні кроки