# Вебхуки

Вебхуки — не приложение к API, а его половина. Без них интеграция превращается
в опрос по расписанию: «не появилось ли чего» каждую минуту.

Эта страница — вторая из трёх в разделе, которые описаны **по-настоящему
и не изменятся**: формат конверта и модель доставки решены, реализация их
не поменяет.

## Подписка

```bash
curl -X POST 'https://school.example.com/api/v1/webhooks' \
  -H 'Authorization: Bearer lms_live_a8f3c1e9d2b74a5f8e0c1d3b5a7f9e2c' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  --data '{
    "url": "https://my-integration.example.com/hooks/lms",
    "events": ["order.paid", "submission.created", "lesson.completed"],
    "secret": "whsec_16d8f4a2c9b7e3510d6a8f2c4b9e7d13",
    "active": true,
    "description": "Синхронизация с amoCRM"
  }'
```

Подписаться можно на группу маской — `order.*`, `submission.*` — или на всё сразу: `*`.
Полный перечень имён — [Каталог событий](/api/events/), 82 события в девяти группах.

## Один конверт на все события

Это принципиально, а не деталь оформления. У GetCourse три несовместимые схемы:
у заказов одно имя поля события и время в одном формате, у диалогов другое имя
и другой формат, у звонков третья схема. Единый обработчик там написать нельзя.

У нас конверт один:

```json
{
  "id": "evt_01HQZXB4N7RS2F",
  "type": "order.paid",
  "created_at": "2026-08-03T14:30:00+03:00",
  "api_version": "v1",
  "data": {
    "object": { "id": "ord_01HQZX68HKYJ1X", "status": "paid", "amount": 1490000, "currency": "RUB" },
    "previous": { "status": "waiting_payment" }
  },
  "related": {
    "user_id": "usr_01HQZX3M8K4N2P",
    "payment_id": "pay_01HQZX6TSCZL8Y"
  }
}
```

Два поля, ради которых стоит читать конверт целиком:

**`previous`** есть у событий изменения и содержит то, что было **до**. Позволяет
понять, что именно поменялось, не запрашивая объект повторно. Без него на каждое
`order.updated` пришлось бы делать запрос, чтобы узнать, изменилась ли интересующая
вас сумма.

**`related`** даёт идентификаторы связанных сущностей. Тоже ради того, чтобы не
делать лишний запрос ради одного поля.

Оговорка честности: **что именно лежит в `data.object` у каждого типа события,
пока не описано** — событий в коде ещё нет. Форма конверта решена и не изменится,
состав `object` появится вместе с реализацией.

## Подпись

```
X-LMS-Signature: t=1785790800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

HMAC-SHA256 от строки `"{timestamp}.{сырое тело}"` с секретом подписки.

**Считается по сырому телу, до разбора JSON.** Это единственный способ, при котором
подпись не ломается от порядка ключей и лишних пробелов: разобрав и собрав JSON
обратно, вы получите другие байты и другую подпись. В большинстве фреймворков тело
разбирается автоматически — на маршруте вебхука это надо отключить и взять байты.

Толерантность по времени — **300 секунд**. Метка времени в подписи проверяется:
перехваченный запрос нельзя проиграть через час.

```
1. Взять сырое тело запроса, до разбора.
2. Взять t и v1 из заголовка X-LMS-Signature.
3. Проверить, что |сейчас − t| ≤ 300 секунд.
4. Посчитать HMAC-SHA256 от "t.тело" с секретом подписки.
5. Сравнить с v1 сравнением, постоянным по времени.
```

Шаг 5 — не педантизм: обычное посимвольное сравнение утекает подпись по времени
ответа, и это известная атака, а не теоретическая.

У GetCourse подписи нет вообще: единственная защита — неугадываемый адрес.

## Доставка и повторы

Успех — любой ответ `2xx` **в течение 10 секунд**. Всё остальное считается неудачей,
включая `3xx`.

Из этого следует практическое правило: **отвечайте `200` сразу, а работайте потом.**
Синхронная обработка внутри обработчика вебхука — самый частый способ получить
таймаут и шесть повторов одного события.

Повторы с возрастающей задержкой: через **1 минуту, 5 минут, 30 минут, 2 часа,
12 часов, 24 часа**. Шесть попыток примерно за сутки. После — доставка помечается
проваленной, администратору приходит уведомление.

Каждая попытка пишется в журнал доставок: код ответа, тело ответа (первые 4 КБ),
длительность. В интерфейсе — список с фильтром «только неудачные» и кнопкой
«повторить»; по API — отдельная операция. Ручной повтор **не увеличивает счётчик
автоматических попыток** и не сдвигает их расписание: это отдельная запись.

## Переигрывание пропущенного

Если приёмник лежал сутки, восстанавливать данные массовой выгрузкой не нужно.
Переигрывание пересобирает доставки из журнала событий — по диапазону дат,
по списку типов или по обоим.

**Уже доставленные переигрываются тоже**, если попали в выборку. Поэтому получатель
обязан быть идемпотентным (см. ниже), а переигранная доставка помечена в конверте,
чтобы её можно было отличить.

Это работает только потому, что события хранятся в базе, а не только в очереди:
они пишутся в ту же транзакцию, что и бизнес-операция.

## Порядок и дубликаты

Две вещи, о которых лучше сказать прямо, чем делать вид, что их нет.

**Порядок доставки не гарантируется.** Даже по одному объекту. `order.paid` может
прийти раньше `order.created`. Что с этим делать:

- **читайте текущее состояние объекта**, а не достраивайте его из истории событий;
- если порядок нужен — упорядочьте сами: у каждого события есть `created_at`
  и монотонный номер;
- не пишите обработчик, который полагается на «предыдущее событие уже обработано».

**Доставка как минимум один раз.** Событие может прийти дважды — при повторе,
при переигрывании, при таймауте на вашей стороне после успешной обработки.
Обработчик обязан быть **идемпотентным по `event.id`**:

```
1. Взять event.id из конверта.
2. Попытаться вставить его в свою таблицу обработанных с уникальным индексом.
3. Вставка не прошла — событие уже обработано, ответить 200 и выйти.
4. Вставка прошла — обработать.
```

Уникальный индекс, а не проверка «есть ли запись» перед вставкой: два экземпляра
обработчика пройдут проверку одновременно и оба обработают.

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

Есть тестовая отправка: она шлёт на ваш адрес настоящий конверт с настоящей подписью.
Ей стоит пользоваться до того, как настроите событие, а не после — так проверяются
адрес, сертификат и разбор подписи по отдельности от бизнес-логики.
