> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thunderphone.com/llms.txt
> Use this file to discover all available pages before exploring further.

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

> Каждый вебхук и запрос к инструменту от ThunderPhone подписан. Проверьте один раз — используйте повсюду.

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

## Алгоритм

1. Считайте **исходное** тело запроса — точные байты, которые мы отправили вам через POST.
2. Вычислите `hmac_sha256(secret, body).hexdigest()`.
3. Сравните за **постоянное время** со значением `X-ThunderPhone-Signature`.
   (Наивное сравнение строк раскрывает информацию о времени выполнения.)

Мы подписываем ровно те байты, которые передаём, поэтому проверка исходного тела
всегда работает. Эти байты также являются **канонической сериализацией JSON**
полезной нагрузки — ключи отсортированы по алфавиту, компактные разделители
(`,` и `:` без пробелов), UTF-8. Это даёт вам второй, полностью
эквивалентный способ, если ваш фреймворк предоставляет только разобранный JSON:
повторно сериализуйте данные канонически и вычислите для них HMAC.

```python theme={null}
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
```

Предпочтительно использовать исходное тело — это на один шаг меньше и позволяет
избежать особенностей повторного преобразования чисел JSON в некоторых языках.

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

| Источник                                                                                   | Секрет                                                                                                                            |
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| [Конечная точка webhook](/ru/webhooks/endpoints) (`/v1/developer/webhook-endpoints`)       | Секрет `secret` для каждой конечной точки (48 шестнадцатеричных символов), возвращаемый один раз при создании                     |
| [Устаревший webhook с одним URL](/api-reference/organizations#legacy-single-url-webhook)   | Секрет `secret` для организации, возвращаемый при `GET /v1/webhook`                                                               |
| [Вызов конечной точки инструмента](/ru/tools/overview) (прямой вызов вашей `endpoint.url`) | **Секрет webhook на уровне организации** (тот же, что и для устаревшего webhook с одним URL) — не секрет отдельной конечной точки |

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

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

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

<CodeGroup>
  ```python Python theme={null}
  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 "")
  ```

  ```javascript Node.js theme={null}
  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 Go theme={null}
  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 Ruby theme={null}
  require "openssl"

  def verify(body, signature, secret)
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
    Rack::Utils.secure_compare(expected, signature.to_s)
  end
  ```
</CodeGroup>

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

<CodeGroup>
  ```python FastAPI theme={null}
  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}
  ```

  ```javascript Express theme={null}
  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);
    },
  );
  ```

  ```python Django theme={null}
  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})
  ```
</CodeGroup>

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

Когда агент напрямую вызывает один из ваших
[функциональных инструментов](/ru/tools/overview) (у инструмента есть
`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`.

```python theme={null}
@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`)
— это обычный подписанный вебхук, поэтому применяется стандартный рецепт
выше. Оба формата запросов описаны в разделе
[Функциональные инструменты](/ru/tools/overview).

## Распространённые ошибки

<AccordionGroup>
  <Accordion title="Повторная сериализация со стандартным форматированием">
    Разбор тела и его повторная сериализация с настройками по умолчанию
    вашей JSON-библиотеки (пробелы после `,` / `:`, ключи в порядке вставки) создают
    другие байты и нарушают HMAC. Проверяйте исходное тело — или, если
    необходимо повторно сериализовать его, точно соблюдайте нашу каноническую форму: отсортированные
    ключи, компактные разделители, UTF-8.
  </Accordion>

  <Accordion title="Фреймворк автоматически разбирает JSON">
    Промежуточное ПО Express `express.json()` считывает поток тела,
    и вы теряете исходные байты. Используйте `express.raw()` специально для маршрута
    вебхука или буферизуйте исходное тело в предварительном промежуточном ПО.
    То же относится к NestJS / Koa — ознакомьтесь с их документацией по «исходному телу».
  </Accordion>

  <Accordion title="Сравнение, небезопасное по времени">
    `expected === signature` в JS или `expected == signature` в
    Python — это сравнения с переменным временем выполнения. Используйте `crypto.timingSafeEqual`
    или `hmac.compare_digest` соответственно. Разница в производительности
    отсутствует.
  </Accordion>

  <Accordion title="Неверный секрет для эндпоинтов инструментов">
    Прямые вызовы эндпоинтов инструментов подписываются с помощью **секрета вебхука
    на уровне организации** (`GET /v1/webhook`) — а не каким-либо секретом для конкретного эндпоинта
    из `/v1/developer/webhook-endpoints`. Повторно используйте ту же функцию `verify()`,
    но убедитесь, что передаёте ей секрет организации для маршрутов инструментов.
  </Accordion>

  <Accordion title="Хеширование строки запроса для инструментов GET/DELETE">
    Для методов инструментов без тела подпись охватывает пустую строку байтов,
    сохраняя единый универсальный подход: вычисляйте HMAC от исходного тела запроса,
    каким бы оно ни было. Хеш URL или строки запроса никогда не совпадёт.
  </Accordion>

  <Accordion title="Не возвращать 401 при несовпадении">
    Возврат 200 при неудачной проверке делает обработчик целью для повторной атаки.
    Всегда отвечайте статусом не из диапазона 2xx, если проверка не пройдена.
  </Accordion>
</AccordionGroup>

***

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

<CardGroup cols={2}>
  <Card title="Обзор вебхуков" icon="bolt" href="/ru/webhooks/overview">
    Семантика доставки, повторы, исходные IP-адреса.
  </Card>

  <Card title="Эндпоинты вебхуков" icon="plug" href="/ru/webhooks/endpoints">
    Управляйте несколькими URL, ротируйте секреты.
  </Card>

  <Card title="Инструменты-функции" icon="screwdriver-wrench" href="/ru/tools/overview">
    Два пути вызова инструментов и формы их запросов.
  </Card>

  <Card title="Интеграции инструментов" icon="wrench" href="/ru/guides/build-tool-integration">
    Создайте полную интеграцию на основе инструментов от начала до конца.
  </Card>
</CardGroup>
