---
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` |
| path and query | Ціль запиту з рядка 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) містять ціль запиту саме в отриманому вигляді. Django
`get_full_path()` і Flask `request.path` спочатку декодують шлях, що
дає той самий рядок для звичайних шляхів. Якщо шлях вашої кінцевої точки містить
відсотково закодовані або зарезервовані символи, як-от `%2F`, `;` чи `=`, натомість зчитайте
необроблену ціль запиту, яку надає ваш сервер (наприклад, `request.META["RAW_URI"]` у
Gunicorn).

## Який секрет?

Той самий секрет підписує обидва заголовки.

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

Зберігайте секрет у менеджері секретів або змінній середовища — ніколи не додавайте його до коміту.

## Перевірка викликів інструментів

Коли агент безпосередньо викликає один із ваших
[інструментів функцій](/uk/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`) є звичайним
підписаним вебхуком — застосовується стандартний спосіб, описаний вище. Дивіться
[Функціональні інструменти](/uk/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="/uk/webhooks/overview">
    Семантика доставки, повторні спроби, IP-адреси джерел.
  </Card>
  <Card title="Кінцеві точки вебхуків" icon="plug" href="/uk/webhooks/endpoints">
    Керуйте кількома URL-адресами, змінюйте секрети.
  </Card>
  <Card title="Функціональні інструменти" icon="screwdriver-wrench" href="/uk/tools/overview">
    Два шляхи виклику інструментів і форми їхніх запитів.
  </Card>
  <Card title="Інтеграції інструментів" icon="wrench" href="/uk/guides/build-tool-integration">
    Створіть повну інтеграцію на основі інструментів від початку до кінця.
  </Card>
</CardGroup>
