# Вебхуки

Исход хода приходит к вам сам. Опрос (`GET /v1/turns/{id}`) остаётся, но это
запасной путь: ход идёт минуты, и спрашивать о нём каждую секунду — значит
греть сеть ради ответа, который придёт один раз.

Адрес берётся у разговора (`webhook_url` при заведении), а если он не задан —
у партнёра. Адрес разговора перебивает общий: так у вас могут жить разные
приёмники для разных контуров.

## Что приходит

```
POST https://ваш-сервис/hooks/teril
Content-Type: application/json
X-Teril-Event: turn.completed
X-Teril-Signature: t=1758182400,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

```json
{
  "id": "4e1a…",
  "event": "turn.completed",
  "at": "2026-09-18T07:20:00.000Z",
  "turn_id": "b41c…",
  "conversation_id": "3f2b…",
  "external_id": "lead-7781",
  "state": "done",
  "result": {
    "status": "built",
    "note": "Собрал кухню 3 метра…",
    "applied": 9,
    "turn": 1,
    "order_id": "lead-7781",
    "usage": { "coins": 1, "cost_usd": 0.0231, "ms": 41200 }
  }
}
```

События:

| `event` | Когда |
|---|---|
| `turn.completed` | Ход кончился. Что именно вышло — в `result.status` |
| `turn.stopped` | Ход оборван вашим `POST /v1/turns/{id}/stop` |
| `turn.failed` | Ход не состоялся: связь, срок, перезапуск сервиса |

У неудачного хода вместо `result` приходит `error`:

```json
{ "id": "…", "event": "turn.failed", "turn_id": "…", "state": "failed",
  "error": { "code": "internal", "message": "Ход оборвался: сервис был перезапущен." } }
```

**`turn.completed` — это не всегда «собрано».** Ассистент мог законно ответить
словами (`status: "question"`). Решение принимайте по `result.status`, а не по
имени события.

## Подпись

```
X-Teril-Signature: t=<unix-секунды>,v1=<hex hmac-sha256>
```

Подписывается строка `"<t>.<тело запроса как есть>"`, ключ — секрет вашего
партнёрского профиля. Проверяйте **до** разбора JSON и по сырому телу: любая
перекодировка ломает подпись.

Проверять надо обе вещи:

1. подпись сходится с вашей, посчитанной тем же способом;
2. `t` не старше пяти минут.

Время внутри подписи не для красоты: без него перехваченный запрос можно
переслать вам через сутки с той же подписью, и отличить его от повторной
доставки будет нечем.

Сравнивайте подписи функцией, **постоянной по времени** (`crypto.timingSafeEqual`,
`hmac.compare_digest`). Побайтовое сравнение через `===` отдаёт секрет
посимвольно тому, кто умеет мерить время ответа.

Готовые примеры: [examples/verify.mjs](examples/verify.mjs) (Node)
и [examples/verify.py](examples/verify.py) (Python).

## Как отвечать

Отвечайте **2xx как можно быстрее**: мы ждём «принял», а не «обработал».
Поставьте событие в свою очередь и отвечайте сразу. Ждём ответа 10 секунд.

Любой не-2xx и любой обрыв — повод повторить.

## Повторы

Повтор идёт через **10 с, 1 мин, 5 мин, 30 мин, 2 ч, 6 ч**, после чего мы
замолкаем. Приёмник, не отвечающий шесть часов, чинится руками, а исход всё
это время достаётся опросом `GET /v1/turns/{id}`.

**Доставка at-least-once.** Одно и то же событие может прийти дважды: ответ
потерялся, ваш сервис перезапустился, прокси оборвал соединение. Поле `id`
события стабильно — отсеивайте повтор по нему.

## Чего вебхук не гарантирует

Исход и запись о нём пишутся одной транзакцией, поэтому **исхода без записи
не бывает**: `GET /v1/turns/{id}` всегда покажет правду. Обратное возможно:
если сервис упал ровно между списанием и постановкой уведомления в очередь,
вебхук не придёт вовсе. Поэтому держите защитный опрос для ходов, по которым
за десять минут ничего не пришло.

Порядок доставки не гарантируется. По одному разговору идёт один ход за раз,
так что перепутать исходы двух ходов одного лида нельзя, но события разных
лидов приходят как придутся.
