События

Вебхуки

Вебхук — ваш адрес в интернете, на который лагерь сам отправляет каждое событие вашего отдела сразу, как оно случилось. Не нужно опрашивать API: оплатил родитель счёт — через секунды ваш сервер получает invoice.paid.

Что такое события и какие они бывают — в Событиях. Здесь — как завести вебхук, проверить, что запрос пришёл именно от лагеря, и правильно отвечать. Нужно право webhooks.

Что нужно с вашей стороны

  • Адрес вида https://crm.example.kz/hooks/zvezdnyy на вашем сервере, доступный из интернета.
  • Только https:// с действующим сертификатом (не самоподписанным).
  • Публичный адрес. Внутренние адреса (localhost, 10.x.x.x, 192.168.x.x и т. п.) не принимаются.
  • Без логина и пароля в адресе (https://user:pass@…) и без части после #. Подлинность проверяется подписью, а не паролем.
  • Адрес должен отвечать сам, без переадресации: ответ 301/302 считается неудачей.
  • Не больше 5 действующих вебхуков на подключение.

Шаг 1. Завести вебхук

POST /webhooks с Idempotency-Key:

TerminalCode
curl https://partners.zvezdnyy-burabai.edu.kz/ext/v1/webhooks \ -H "Authorization: Bearer $ZB_KEY" \ -H "Idempotency-Key: 1b6f9e3c-2a7d-4c58-8e04-d5a3b2f1c697" \ -H "Content-Type: application/json" \ -d '{ "url": "https://crm.example.kz/hooks/zvezdnyy", "event_types": ["booking.created", "booking.cancelled", "booking.hold_expired", "booking.secured", "invoice.issued", "invoice.error", "invoice.paid", "payment.received"] }'
  • event_types — какие типы присылать. Не указывать или пустой список — все типы, включая те, что появятся в будущем. Незнакомый тип — отказ 400 bad_event_type. ping указывать не нужно — он приходит только на проверку.

Ответ 201:

JSONCode
{ "id": "17", "url": "https://crm.example.kz/hooks/zvezdnyy", "enabled": true, "event_types": ["booking.created", "booking.cancelled", "booking.hold_expired", "booking.secured", "invoice.issued", "invoice.error", "invoice.paid", "payment.received"], "delivered_through": "1042", "failed_attempts": 0, "failing_since": null, "next_attempt_at": null, "disabled_reason": null, "created_at": "2027-05-10T07:00:00.000000Z", "secret": "whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" }

Секрет показывается один раз

secret — ключ для проверки подписи. Он есть только в этом ответе (и в ответе на смену секрета). Сразу сохраните его в настройках сервера рядом с ключом API. Потеряли — смените секрет (POST /webhooks/{id}/rotate-secret).

Новый вебхук начинает с текущего конца ленты (delivered_through — номер последнего события на момент создания): прошлые события на него не придут. Нужна история — заберите её опросом GET /events или попросите отправить заново (redeliver, ниже).

Шаг 2. Проверить адрес

POST /webhooks/{id}/test — лагерь сразу отправит на адрес событие ping и скажет, что ответил ваш сервер. Ключ повтора не нужен.

TerminalCode
curl -X POST https://partners.zvezdnyy-burabai.edu.kz/ext/v1/webhooks/17/test \ -H "Authorization: Bearer $ZB_KEY"
JSONCode
{ "ok": true, "status": 200, "duration_ms": 184, "error": null }

ok: false и status: null — ответа не было вовсе: адрес не найден, нет соединения, плохой сертификат или сервер не ответил за 10 секунд. Описание — в error.

Что приходит на ваш адрес

Лагерь отправляет POST с одним событием в теле — в том же виде, что в GET /events:

Code
POST /hooks/zvezdnyy HTTP/1.1 Host: crm.example.kz Content-Type: application/json User-Agent: ZvezdnyyBurabai-Webhooks/1 Zb-Event-Id: 1043 Zb-Event-Type: invoice.paid Zb-Signature: t=1778752965,v1=5257a869e7ec… {"id":"1043","type":"invoice.paid","occurred_at":"2027-05-14T10:02:45Z","entity":{"type":"invoice","id":"731"},"object":{…},"data":null}
ЗаголовокЧто это
Zb-Event-IdНомер события — то же, что id в теле
Zb-Event-TypeТип события — то же, что type в теле
Zb-SignatureПодпись: время отправки t и значение v1

Шаг 3. Проверять подпись

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

  1. Разберите заголовок Zb-Signature

    Это пары ключ=значение через запятую: t=1778752965,v1=5257a869…. t — время отправки (секунды Unix). v1 может быть несколько — подпись верна, если совпало хотя бы одно.

  2. Возьмите сырое тело запроса

    Байты как пришли, до разбора JSON. Если разобрать JSON и собрать обратно, байты будут другими (пробелы, порядок полей, экранирование кириллицы), и подпись не сойдётся.

  3. Посчитайте HMAC-SHA256

    Ключ — секрет целиком, вместе с whsec_. Сообщение — строка <t>.<сырое тело>: время, точка, тело. Результат — в шестнадцатеричном виде (64 символа, строчные буквы).

  4. Сравните с каждым v1 — сравнением постоянного времени (timingSafeEqual, hmac.compare_digest, hash_equals), а не обычным ==.

  5. Отбросьте запрос, если t отличается от текущего времени больше чем на 5 минут. Так перехваченный запрос нельзя отправить вам повторно через неделю. Часы вашего сервера должны идти точно (NTP).

Подпись не сошлась — ответьте 400 или 401 и не обрабатывайте событие.

import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.ZB_WEBHOOK_SECRET; // whsec_… function verify(secret, header, rawBody) { if (!header) return false; const parts = header.split(",").map((p) => p.trim().split("=")); const t = (parts.find(([k]) => k === "t") || [])[1]; const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v); if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const mac = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"); return sigs.some( (v1) => v1.length === mac.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1)), ); } const app = express(); // express.raw, а не express.json: нужно тело байтами, как пришло. app.post("/hooks/zvezdnyy", express.raw({ type: "application/json" }), async (req, res) => { if (!verify(SECRET, req.get("Zb-Signature"), req.body)) return res.sendStatus(401); const event = JSON.parse(req.body.toString("utf8")); await queue.push(event); // положить в свою очередь и сразу ответить res.sendStatus(200); }); app.listen(3000);

Проверьте свой код на эталоне

Если ваша функция проверки верна, для этих данных она посчитает ровно такой v1:

ЧтоЗначение
Секретwhsec_test
t1700000000
Тело{"id":"1"}
Ожидаемый v111bf4466ea17c3df3fd743af0b435368e16b7a05eb8eced85e8c4670767bdec5

То же в терминале:

TerminalCode
printf '1700000000.{"id":"1"}' | openssl dgst -sha256 -hmac whsec_test

(Проверку времени для этого эталона отключите: 1700000000 — это ноябрь 2023 года.)

Шаг 4. Отвечать правильно

  • Ответьте 2xx (лучше 200) не позже чем за 10 секунд. Не обрабатывайте событие прямо в обработчике: положите его в свою очередь (таблицу, Redis, RabbitMQ) и сразу ответьте. Разбирайте очередь отдельно.
  • Повтор — тоже 200. Если событие с этим Zb-Event-Id вы уже обработали, ответьте 200 и пропустите его. Иначе лагерь будет повторять его снова и снова.
  • Незнакомый тип — тоже 200. Пропустите, но не отвечайте ошибкой.
  • Тело ответа не важно — лагерь его не читает (кроме журнала доставок при ошибке).

Порядок и повторы

  • Строго по порядку. На один вебхук события уходят по одному, по возрастанию номера. Следующее уходит, только когда ваш сервер ответил 2xx на предыдущее.
  • Это значит: пока вы не ответили 2xx на событие, следующие не придут. Одно событие, на котором ваш обработчик падает, останавливает доставку всех остальных. Поэтому — сначала в очередь и 200, потом разбор.
  • Не ответили 2xx (ошибка, таймаут, переадресация) — повтор через 30 секунд, 2 минуты, 10 минут, 30 минут, 1 час и дальше каждые 2 часа.
  • Трое суток неудач подряд — вебхук отключается (enabled: false, причина в disabled_reason), на почту подключения приходит письмо.
  • События не теряются. Пока вебхук стоял, лента копилась. Почините сервер и включите вебхук: PATCH /webhooks/{id} с {"enabled": true} — доставка продолжится с того же места. Или заберите события опросом GET /events.

Управление вебхуком

НужноЗапрос
Список вебхуковGET /webhooks
Один вебхук и его состояниеGET /webhooks/{id} — failed_attempts, failing_since, next_attempt_at
Сменить адрес или типы событийPATCH /webhooks/{id} с url, event_types
Включить снова (сбрасывает счёт неудач)PATCH /webhooks/{id} с {"enabled": true}
ОтключитьDELETE /webhooks/{id} — остаётся в списке отключённым, можно включить снова
Проверить адресPOST /webhooks/{id}/test
Журнал попыток за 30 днейGET /webhooks/{id}/deliveries — код ответа вашего сервера, начало ответа, время
Отправить заново с нужного местаPOST /webhooks/{id}/redeliver с {"after": "1000"} (с Idempotency-Key)
Сменить секрет подписиPOST /webhooks/{id}/rotate-secret (с Idempotency-Key)

Отправить заново (redeliver): всё после номера after уйдёт снова, по порядку, в ближайшие секунды. Номер меньше уже доставленного — повтор прошлых событий; больше — пропуск событий до него. События хранятся 90 дней.

Смена секрета

POST /webhooks/{id}/rotate-secret возвращает новый secret — один раз. Старый секрет перестаёт действовать сразу: события, отправленные после ответа, подписаны новым. Как сменить без потерь:

  1. Добавьте в настройки сервера место для второго секрета.
  2. Смените секрет и сохраните новый, не удаляя старый.
  3. На время перехода принимайте подпись, если она сходится с любым из двух секретов (событие, отправленное за секунду до смены, подписано старым).
  4. Когда пришло первое событие с новой подписью — удалите старый секрет.

Частые проблемы

Что видноПричина
Подпись никогда не сходитсяТело разобрано и собрано заново (express.json(), $request->all()), или секрет взят без whsec_
Подпись то сходится, то нетЧасы сервера отстают или спешат больше чем на 5 минут
События перестали приходитьОбработчик падает на одном событии и не отвечает 2xx — смотрите GET /webhooks/{id}/deliveries
В журнале «Переадресация на …»Адрес отвечает 301/302 — укажите конечный адрес (часто — https вместо http или слэш в конце)
test отвечает status: nullАдрес не виден из интернета, закрыт файрволом или сертификат недействителен
Last modified on