> ## 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 підписано. Перевірте один раз — використовуйте всюди.

Кожен запит, який ми надсилаємо на ваш сервер — доставки вебхуків і
виклики кінцевих точок інструментів — містить підпис 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 у деяких мовах.

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

| Джерело                                                                                          | Секрет                                                                                                                                        |
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| [Кінцева точка вебхука](/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-адресою) — не секрет для окремої кінцевої точки |

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

## Довідкові реалізації

Усі чотири перевіряють необроблене тіло запиту:

<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>

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

Коли агент безпосередньо викликає один із ваших
[функціональних інструментів](/uk/tools/overview) (інструмент має
`endpoint`), запит містить два заголовки ThunderPhone разом із
налаштованими вами `endpoint.headers`:

* `X-ThunderPhone-Call-ID` — числовий ідентифікатор активного дзвінка.
* `X-ThunderPhone-Signature` — HMAC-SHA256 із ключем у вигляді вашого
  **секрету webhook на рівні організації**, обчислений для точних байтів тіла запиту.

Той самий допоміжний метод `verify()` працює без змін, але є два нюанси:

1. **Інструменти `GET` / `DELETE` не мають тіла.** Аргументи передаються як параметри
   запиту, а підпис обчислюється для **порожнього рядка байтів** —
   тобто `verify(b"", sig, secret)` (Python) або
   `verify(Buffer.alloc(0), sig, secret)` (Node). Не хешуйте
   рядок запиту.
2. **Організації без налаштованого застарілого webhook не мають секрету організації.** У
   такому разі виклики інструментів містять лише `X-ThunderPhone-Call-ID` і не мають
   заголовка підпису. Налаштуйте застарілий webhook
   (`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)
    ...
```

Диспетчеризація інструментів у **режимі webhook** (інструменти без `endpoint`, що доставляються
до webhook вашої організації як `telephony.tool` / `web.tool`) є звичайним
підписаним webhook — застосовуйте стандартний підхід вище. Див. розділ
[Функціональні інструменти](/uk/tools/overview) для обох форматів запитів.

## Поширені помилки

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

  <Accordion title="Фреймворк автоматично аналізує JSON">
    Проміжне ПЗ Express `express.json()` споживає потік тіла запиту,
    і ви втрачаєте необроблені байти. Використовуйте `express.raw()` безпосередньо для маршруту
    вебхука або буферизуйте необроблене тіло в попередньому проміжному ПЗ.
    Те саме стосується NestJS / Koa — перегляньте їхню документацію щодо «raw body».
  </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="/uk/webhooks/overview">
    Семантика доставки, повторні спроби, IP-адреси джерел.
  </Card>

  <Card title="Кінцеві точки вебхуків" icon="plug" href="/uk/webhooks/endpoints">
    Керуйте кількома URL-адресами, змінюйте секрети.
  </Card>

  <Card title="Function Tools" icon="screwdriver-wrench" href="/uk/tools/overview">
    Два шляхи виклику інструментів і формати їхніх запитів.
  </Card>

  <Card title="Інтеграції інструментів" icon="wrench" href="/uk/guides/build-tool-integration">
    Створіть повну інтеграцію на основі інструментів від початку до кінця.
  </Card>
</CardGroup>
