Вебхуки
Вебхуки — не приложение к API, а его половина. Без них интеграция превращается в опрос по расписанию: «не появилось ли чего» каждую минуту.
Эта страница — вторая из трёх в разделе, которые описаны по-настоящему и не изменятся: формат конверта и модель доставки решены, реализация их не поменяет.
Подписка #
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.* — или на всё сразу: *.
Полный перечень имён — Каталог событий, 82 события в девяти группах.
Один конверт на все события #
Это принципиально, а не деталь оформления. У GetCourse три несовместимые схемы: у заказов одно имя поля события и время в одном формате, у диалогов другое имя и другой формат, у звонков третья схема. Единый обработчик там написать нельзя.
У нас конверт один:
{
"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. Вставка прошла — обработать.
Уникальный индекс, а не проверка «есть ли запись» перед вставкой: два экземпляра обработчика пройдут проверку одновременно и оба обработают.
Проверка подписки #
Есть тестовая отправка: она шлёт на ваш адрес настоящий конверт с настоящей подписью. Ей стоит пользоваться до того, как настроите событие, а не после — так проверяются адрес, сертификат и разбор подписи по отдельности от бизнес-логики.