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

# Вебхуки

> События `call_ended` и `call_analyzed`, подпись, повторы и порядок доставки.

# Вебхуки

Вебхуки — основной канал результатов; опрос `GET /v1/calls/{call_id}` — запасной. Администратор регистрирует адрес **на агенте**: откройте агента в редакторе → вкладка **«Настройки»** → раздел **«Вебхуки (после звонка)»**. Содержимое полезной нагрузки описано также на странице [«Вебхуки»](/ru/v4/webhooks).

* **URL** — `http://` или `https://` (платформа не требует HTTPS; используйте его всё равно). Доставка подчиняется политике исходящих адресов платформы: приватные адреса и запрещённые хосты могут быть отклонены конфигурацией оператора, и такие доставки уходят в очередь недоставленных.
* **Подписка на события** — любое подмножество трёх событий ниже (по умолчанию `call_ended` + `call_analyzed`).
* **Секрет подписи** — задаётся на стороне платформы **ссылкой на серверный секрет** (именем переменной окружения развёртывания); само значение в кабинет не вводится и передаётся вам отдельным каналом.
* **Агент** — адрес привязан ровно к одному живому агенту организации. Поле обязательное: пустое имя агента отклоняется с кодом `webhooks.agent_required`, неизвестный или архивный — с кодом `webhooks.agent_not_found`. Уровня «вся организация» не бывает: чтобы покрыть несколько агентов, зарегистрируйте адрес у каждого (URL можно переиспользовать, различать по `agent_id` в payload).
* **Тестовые события** — отдельный флаг, при котором адрес получает и события тестовых сессий редактора (`mode: "test"`).
* **Таймаут** — сколько платформа ждёт ваш `2xx` (по умолчанию платформы 10 с, настраивается по адресу до 120 с; `0` означает значение платформы).

### Конверт события

Каждое событие — HTTP(S) `POST` с телом JSON (UTF-8, не-ASCII символы не экранируются).

| Поле                              | Описание                                                                                                                               |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `event`                           | `call_ended` \| `call_analyzed` \| `observer_incident` (плюс `ping` для тестовых доставок)                                             |
| `event_id`                        | Уникальный идентификатор события (`evt-` + 32 hex). Одинаков при повторных доставках и на разных адресах — **дедуплицируйте по нему**. |
| `timestamp`                       | Когда событие собрано, секунды эпохи.                                                                                                  |
| `call_id`                         | Сопоставляется с объектом звонка.                                                                                                      |
| `agent_id`                        | Имя агента.                                                                                                                            |
| `flow_version`, `flow_version_id` | Отметка версии сценария (та же оговорка о типе, что в объекте звонка) и идентификатор опубликованной версии.                           |
| `metadata`                        | Ваша нагрузка из создания звонка — присутствует в **каждом** событии.                                                                  |

Заголовки каждой доставки:

```
Content-Type: application/json
User-Agent: conversation-flow-webhooks/1.0
X-CFlow-Event: call_ended
X-CFlow-Event-Id: evt-4f0c22b17a9d4e3c8b6a5f019e2d7c31
X-CFlow-Timestamp: 1753344187
X-CFlow-Signature: v1=6f2a45c1e8…
```

### Какое событие когда приходит

* **`call_ended`** — как только завершилась сессия, до анализа и обычно до финализации записи. Это раннее уведомление: транскрипт, `call_status`, `status_reason`, `error_code` и поля времени (`answered_at`, `media_started_at`, `talk_from`, `talk_to`). `latency` присутствует только если собирались метрики по тёрнам; её ключи — сырые имена метрик, оканчивающиеся на `_ms` (`turn_ms`, `llm_ms`, `asr_ms`, `tts_ms`, …), значения — `{avg, max}` в миллисекундах.
* **`call_analyzed`** — когда закончились **оба** пост-конвейера: анализ (терминальный в любом из `done`, `skipped`, `failed`, `interrupted` — событие приходит всегда) и финализация записи. Событие **самодостаточно**: полный блок звонка + анализ + готовая запись. Если нужен только итог, подпишитесь на один `call_analyzed` и пропустите `call_ended`. Его блок `call` идентичен `call_ended.call`, за исключением того, что запись остаётся на верхнем уровне и внутри `call` не дублируется.
* **`observer_incident`** — **во время** звонка, когда наблюдатель соответствия, настроенный на агенте, фиксирует нарушение и его реакция включает уведомление. Используйте для оповещения супервизора в реальном времени. `severity` — `low` | `medium` | `high` (неизвестное значение приводится к `medium`), `confidence` — 0–1 или `null`, `mode` может быть `""`, если неизвестен. Несколько инцидентов в одном звонке дают отдельные события; повторы одного инцидента сохраняют тот же `event_id`.
* **`ping`** — тестовая доставка по кнопке в кабинете; `agent_id` — агент, к которому привязан адрес (`""` для адреса уровня организации). Ответьте `2xx` и проверьте подпись как у настоящего события. Не повторяется.

Когда `analysis_status` не `done`, `call_analysis` равен `{}`, а поле верхнего уровня `reason` объясняет причину (например `voicemail`, `no_speech`, `analytics_disabled`); `analytics_tier`, `analytics_model_used` и `analytics_language` тогда пустые строки.

### Семантика доставки

* **Не менее одного раза.** Доставка считается успешной только по `2xx` от вашего адреса; всё остальное (не-`2xx`, таймаут, ошибка соединения) повторяется. Дубли вы **будете** получать — дедуплицируйте по `event_id`.
* **Повторы с экспоненциальной задержкой** — это настройки платформы, а не адреса: по умолчанию до 8 попыток с паузами 30, 60, 120, 240, 480, 960 и 1920 секунд. После последней неудачной попытки событие уходит в очередь недоставленных, откуда администратор может доставить его вручную (История → детали сессии → доставки вебхуков). Недоставленные со временем вычищаются, поэтому окно ручного повтора конечно.
* **Доставки всегда идут на текущий адрес**, поэтому опечатку в URL можно исправить и событие доставить заново; удалённый или отключённый адрес отправляет свои ожидающие события прямо в очередь недоставленных.
* **Порядок — по возможности.** `call_ended` попадает в очередь раньше `call_analyzed`, но порядок доставки не гарантирован; `observer_incident` приходит посреди звонка. Обрабатывайте каждое событие независимо, сопоставляя по `call_id`.
* **Отвечайте быстро.** Возвращайте `2xx` сразу после проверки подписи и обрабатывайте асинхронно: платформа ждёт не дольше таймаута адреса.

### Проверка подписи

```
signed_payload = "{X-CFlow-Timestamp}." + сырое тело запроса
X-CFlow-Signature: v1=<hex( HMAC-SHA256(secret, signed_payload) )>
```

`X-CFlow-Timestamp` — целое число секунд эпохи. Проверяйте по **сырым байтам** тела (до разбора JSON и повторной сериализации — тело в UTF-8 с неэкранированными не-ASCII символами), сравнивайте за постоянное время и отклоняйте устаревшие метки — окно 300 секунд. Повторные доставки переподписываются свежей меткой, поэтому окно с повторами не конфликтует.

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_webhook(
      secret: str,
      signature_header: str,   # X-CFlow-Signature, например "v1=6f2a45…"
      timestamp_header: str,   # X-CFlow-Timestamp, например "1753344187"
      raw_body: bytes,         # точные байты тела запроса
      tolerance_seconds: int = 300,
  ) -> bool:
      try:
          ts = int(timestamp_header)
      except (TypeError, ValueError):
          return False
      if abs(time.time() - ts) > tolerance_seconds:
          return False  # защита от переигровки
      signed = f"{ts}.".encode("utf-8") + raw_body
      expected = "v1=" + hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature_header or "")
  ```

  ```js Node.js theme={null}
  const crypto = require("crypto");

  function verifyWebhook(secret, signatureHeader, timestampHeader, rawBody, toleranceSeconds = 300) {
    const ts = parseInt(timestampHeader, 10);
    if (!Number.isFinite(ts)) return false;
    if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
    const expected =
      "v1=" +
      crypto
        .createHmac("sha256", secret)
        .update(`${ts}.`)
        .update(rawBody) // Buffer с точными байтами запроса
        .digest("hex");
    const provided = Buffer.from(signatureHeader || "");
    const wanted = Buffer.from(expected);
    return provided.length === wanted.length && crypto.timingSafeEqual(provided, wanted);
  }
  ```
</CodeGroup>

В Python (Flask) используйте `request.get_data()`; в Node (Express) — `express.raw({ type: "application/json" })`: оба сохраняют точные байты, по которым считалась подпись.

### Записи разговоров

`recording_url` — **устойчивый** адрес (`https://{{HUBTALK_FQDN}}/api/recordings/rec-…`): он не истекает и его можно сохранять у себя. Каждый `GET` по нему отвечает `307` со свежей короткоживущей ссылкой на скачивание, поэтому адрес, сохранённый месяцы назад, продолжает работать — пока сама запись не удалена политикой хранения (`recording.expires_at`). Другие ответы: `409`, пока запись ещё не готова (продолжайте опрашивать), `410` после удаления, `503` при временной недоступности хранилища. Добавьте `?download=1`, чтобы отдавалось как вложение. Адрес не требует аутентификации, но неугадываем; обращайтесь с ним как с токеном доступа.

Особый случай: если запись финализируется необычно долго (дольше ожидания платформы, по умолчанию 30 с), `call_analyzed` всё равно отправляется — с текущим нетерминальным статусом записи и пустым `recording_url`. Устойчивый адрес начнёт работать сам, когда запись будет готова.
