---
title: "Проверка подписей вебхуков"
description: "Каждый вебхук и запрос к инструменту, отправляемый ThunderPhone, подписывается с меткой времени. Проверяйте подпись с помощью приведённого здесь рецепта или помощника SDK и отклоняйте повторно отправленные запросы старше пяти минут."
---

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

| Заголовок | Подписывает | Защита от повторной отправки |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (рекомендуется) | Временную метку, метод, путь, параметры запроса и тело | Отклоняйте запросы старше 5 минут |
| `X-ThunderPhone-Signature` | Только тело | Нет: перехваченный запрос будет проходить проверку всегда |

Проверяйте `X-ThunderPhone-Signature-V2` в новых интеграциях и перенесите
на него существующие. `X-ThunderPhone-Signature` по-прежнему отправляется с каждым
запросом без изменений, поэтому валидаторы, построенные на нём, продолжают работать. Мы объявим
дату до того, как перестанем его отправлять.

## Подпись V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` — это Unix-время в секундах, когда мы отправили запрос. При повторной
  доставке подпись создаётся заново в момент отправки, с собственным `t`.
- `v1` — это шестнадцатеричный HMAC-SHA256 подписанной строки ниже, вычисленный
  с использованием вашего секрета в качестве ключа.

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

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

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

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| Поле | Значение |
|-------|-------|
| `t` | Значение `t` из заголовка |
| `METHOD` | HTTP-метод в верхнем регистре: `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):

```http
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"}
```

Подписанная строка состоит из этих четырёх строк без перевода строки после тела:

```text
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}
```

Её HMAC-SHA256:
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Проверьте по нему свою реализацию или воспроизведите результат с помощью `openssl`:

```bash
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](/api-reference/sdks) включают вспомогательную функцию
(`thunderphone` 0.2.0 или новее, `@thunderphone/sdk` 0.2.0 или новее). Она
проверяет 5-минутное окно, сравнивает за постоянное время и принимает список
секретов, чтобы вы могли выполнять ротацию без простоя.

<CodeGroup>
```python 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
)
```

```javascript 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
});
```
</CodeGroup>

### Без SDK

<CodeGroup>
```python 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)
```

```javascript 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)
    );
  });
}
```
</CodeGroup>

### Интеграция с фреймворками

Каждый пример считывает исходные путь, параметры запроса и тело запроса, а затем вызывает вспомогательную функцию SDK.

<CodeGroup>
```python 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}
```

```javascript 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);
  },
);
```

```python 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})
```
</CodeGroup>

`req.originalUrl` (Express), а также ASGI `raw_path` и `query_string`
(FastAPI) содержат целевой адрес точно в том виде, в котором он был получен. `get_full_path()` в Django и `request.path` во Flask сначала декодируют путь, поэтому для обычных путей возвращается та же строка. Если путь вашей конечной точки содержит символы с процентным кодированием или зарезервированные символы, например `%2F`, `;` или `=`, вместо этого считайте исходный целевой адрес, предоставляемый сервером (например, `request.META["RAW_URI"]` в
Gunicorn).

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

Один и тот же секрет подписывает оба заголовка.

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

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

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

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

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

## Устаревшая подпись: `X-ThunderPhone-Signature`

Исходный заголовок содержит шестнадцатеричный HMAC-SHA256 только от необработанного тела запроса, с тем же секретом. Он не содержит временной метки, поэтому любой, кто перехватит один подписанный запрос, может воспроизвести его повторно. Он по-прежнему отправляется с каждым запросом, пока интеграции переходят на V2.

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

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

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

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

<CodeGroup>
```python 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 "")
```

```javascript 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 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 Ruby
require "openssl"

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

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

<AccordionGroup>
  <Accordion title="Рассинхронизация часов">
    5-минутное окно измеряется по часам вашего сервера. Синхронизируйте их
    с NTP: если часы отклоняются более чем на 5 минут, все запросы будут
    отклонены.
  </Accordion>

  <Accordion title="Прокси изменяет путь">
    Подписанный путь — это путь, который мы запросили: путь URL, который вы
    настроили в ThunderPhone. Если обратный прокси или API-шлюз удаляет
    или добавляет префикс до того, как ваше приложение увидит запрос, перед
    проверкой восстановите отправленный нами путь (например, верните префикс).
  </Accordion>

  <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`. Используйте тот же
    вспомогательный метод, но убедитесь, что для маршрутов инструментов передаёте
    ему секрет организации.
  </Accordion>

  <Accordion title="Строки запроса в инструментах GET/DELETE">
    V2 подписывает строку запроса в точности в том виде, в котором она отправлена.
    Устаревший заголовок подписывает пустое тело и никогда не подписывает строку
    запроса, поэтому хеширование URL для устаревшего заголовка никогда не даст
    совпадения.
  </Accordion>

  <Accordion title="Дубликаты в пределах окна">
    Метка времени предотвращает повторные атаки старше 5 минут, но не повторную
    доставку в пределах этого окна. Доставка на конечные точки выполняется как
    минимум один раз, поэтому может также прийти повтор того же события;
    устраняйте дубликаты по `event_id` конверта.
  </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>
