События

События

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

В ленту попадает всё, что происходит с вашими записями, кто бы это ни сделал: ваша CRM через API, менеджер вашего отдела в CRM лагеря, сотрудник лагеря, автоматика (снятие по неоплате, счёт на остаток, ответ Kaspi). События хранятся 90 дней. Нужно право read.

Три способа получать события

СпособКакКогда удобен
ВебхукиЛагерь сам отправляет POST на ваш https-адрес, с подписьюЕсть публичный сервер. События приходят сразу. Рекомендуем
ОпросВаша CRM спрашивает GET /events?after=NПроще всего; публичного адреса не нужно. Задержка — как часто опрашиваете
Веб-сокетПостоянное соединение wss://…/ext/v1/streamСобытия сразу без публичного адреса, но нужно держать соединение

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

О вебхуках — отдельная страница: Вебхуки.

Как выглядит событие

JSONCode
{ "id": "1042", "type": "invoice.paid", "occurred_at": "2027-05-14T10:02:45Z", "entity": { "type": "invoice", "id": "731" }, "object": { "id": "731", "booking_id": "15230", "amount": { "amount": "50000.00", "currency": "KZT" }, "state": "paid", "payment_id": "9120", "…": "…" }, "data": null }
ПолеЧто это
idНомер события — он же место в ленте. Только растёт, не повторяется. По нему узнаётся повтор
typeЧто случилось (таблица ниже)
occurred_atКогда случилось, UTC
entityЧего касается: type (booking, invoice, payment, child, parent, shift) и id
objectЗапись целиком — та же, что вернул бы GET по ней. null, если запись вам больше не видна (ребёнок ушёл в другой отдел)
dataПодробности, которых нет в самой записи. Сейчас бывают у booking.moved ({"from_shift_id": "12"}), client.merged и child.merged ({"into_id": "812"}), ping ({"message": "…"}). У остальных — null

object — текущий вид записи, а не снимок на момент события

object собирается в момент отправки события. Если бронь успели изменить ещё раз, в object события booking.created уже будет новая версия. Поэтому:

  • не делайте выводов о прошлом по object — смотрите на type;
  • обновляйте у себя запись по object, только если его version новее той, что у вас (у брони, счёта, платежа есть поле version).

Типы событий

Брони (entity.type = booking):

ТипКогда
booking.createdБронь оформлена (через API, в CRM лагеря, переносом на другую смену), или бронь передали вашему отделу
booking.updatedИзменились данные брони: ФИО, телефоны, цена, скидка, трансфер, комментарий, отряд, срок удержания
booking.cancelledБронь отменена вручную
booking.hold_expiredБронь снята автоматически: минимальный платёж не пришёл за сутки
booking.restoredОтменённая бронь восстановлена
booking.securedМинимальный платёж набран — место закреплено
booking.movedЛагерь перевёл эту бронь на другую смену (data.from_shift_id — с какой)
booking.removedЛагерь передал бронь другому отделу — у вас её больше нет
voucher.readyПутёвка сформирована
voucher.sentПутёвка отправлена семье
child.checked_inРебёнок заехал в лагерь (по брони)
child.checked_outРебёнок уехал из лагеря

Счета Kaspi (invoice): invoice.queued, invoice.issued, invoice.paid, invoice.cancelled, invoice.expired, invoice.error, invoice.mismatch — имя совпадает с новым состоянием счёта (см. Счета Kaspi).

Платежи (payment):

ТипКогда
payment.receivedПришли деньги: оплачен счёт Kaspi или лагерь внёс платёж
payment.refundedЗаписан возврат (платёж с отрицательной суммой)
payment.verifiedБухгалтерия лагеря сверила платёж с банком
payment.updatedЛагерь поправил платёж

Платёж не меняет версию брони и не порождает booking.updated. Получив payment.*, перечитайте бронь (GET /bookings/{object.booking_id}), чтобы обновить «оплачено» и «остаток».

Дети и семьи (child, parent):

ТипКогда
child.addedРебёнок перешёл к вам (вы оформили ему бронь на самую позднюю смену)
child.removedРебёнок ушёл в другой отдел. object — null
child.mergedДве карточки ребёнка объединены, data.into_id — оставшаяся
client.mergedДве карточки семьи объединены, data.into_id — оставшаяся

Подробнее — в Ваша база.

Смены (shift): shift.seats_changed — изменилось число свободных мест вашего отдела (кто-то оформил или отменил бронь, лагерь поменял квоту); shift.status_changed — смена открылась, закрылась, началась, завершилась.

Возвраты (в подготовке, см. Возвраты): refund_request.created, refund_request.approved, refund_request.rejected, refund_request.cancelled, refund_request.done, refund_request.failed.

Проверка вебхука: ping — только в ответ на POST /webhooks/{id}/test.

Список открытый и пополняется. Незнакомый тип — пропустите (ответьте 200, если это вебхук), а не падайте с ошибкой.

Правила, на которые можно опираться

  • Хотя бы один раз. Одно событие может прийти дважды (после переподключения, повтора вебхука). Повтор узнаётся по id: храните номера обработанных событий и пропускайте уже виденные.
  • По порядку. События отдаются по возрастанию id. Номер, раз выданный, не «обгоняется» более ранним: если вы видели событие 1042, событие 1041 позже не появится.
  • Номера идут с пропусками. Лента общая для всех отделов, вам приходят только ваши события — после 1042 может прийти 1057. Это нормально.
  • Ничего не теряется. Событие записывается в той же транзакции, что и само изменение: нет изменения без события и события без изменения.

Опрос

GET /events?after=N — события после номера N.

  1. Храните у себя номер последнего обработанного события. В самом начале — 0 (вся лента за 90 дней).
  2. Запросите GET /events?after=<номер>.
  3. Обработайте items по порядку, после каждого сохраните его id.
  4. has_more: true — за этой порцией есть ещё, запросите сразу с after = next_after. has_more: false — подождите 10–30 секунд и спросите снова.
TerminalCode
curl "https://partners.zvezdnyy-burabai.edu.kz/ext/v1/events?after=1000&limit=100" \ -H "Authorization: Bearer $ZB_KEY"
JSONCode
{ "items": [ { "id": "1041", "type": "booking.created", "occurred_at": "2027-05-14T09:31:07Z", "entity": { "type": "booking", "id": "15230" }, "object": { "id": "15230", "…": "…" }, "data": null }, { "id": "1042", "type": "invoice.paid", "occurred_at": "2027-05-14T10:02:45Z", "entity": { "type": "invoice", "id": "731" }, "object": { "id": "731", "…": "…" }, "data": null } ], "next_after": "1042", "has_more": false }
  • next_after — что передать в after следующего запроса (номер последнего события в порции; если событий не было — ваш же after).
  • limit — от 1 до 500, по умолчанию 100. Больше 100 — «тяжёлый» запрос (не чаще раза в секунду).
  • after больше последнего номера ленты или не число — 400 bad_after.
let after = await loadLastEventId(); // из своей базы; в начале — "0" for (;;) { const res = await fetch( `https://partners.zvezdnyy-burabai.edu.kz/ext/v1/events?after=${after}`, { headers: { Authorization: `Bearer ${process.env.ZB_KEY}` } }, ); if (res.status === 429) { await sleep(Number(res.headers.get("Retry-After") ?? 5) * 1000); continue; } const page = await res.json(); for (const event of page.items) { await handle(event); // повтор узнаётся по event.id await saveLastEventId(event.id); } after = page.next_after; if (!page.has_more) await sleep(15_000); }

Веб-сокет

Адрес — wss://partners.zvezdnyy-burabai.edu.kz/ext/v1/stream. Ключ — в заголовке Authorization запроса на соединение, как у остальных запросов (поэтому из браузера подключиться нельзя — только с сервера). Нужно право read. Одновременно — не больше 3 соединений на подключение; четвёртое получает 429 ещё до соединения.

Обмен — текстовые сообщения JSON:

  1. Сервер сразу шлёт {"type": "hello", "last_event_id": "1042"} — номер последнего события вашего отдела.
  2. Вы шлёте {"type": "subscribe", "after": "1000"}. after обязателен: номер последнего обработанного события ("0" — с начала, за 90 дней). Можно добавить "types": ["booking.created", "invoice.paid"] — тогда придут только эти типы.
  3. Сервер досылает всё пропущенное после after (порциями до 500), затем шлёт новые события по мере появления: {"type": "event", "event": { …событие как в GET /events… }}. Повторный subscribe начинает отдачу заново с нового after.
  4. На неразборчивое сообщение — {"type": "error", "code": "bad_message", "message": "…"}, соединение остаётся.

Что нужно знать о соединении:

  • сервер шлёт ping каждые 30 секунд; нет ответа 90 секунд — закрывает соединение (большинство библиотек отвечают на ping сами);
  • раз в минуту сервер перепроверяет ключ: отозван, истёк, подключение отключено — {"type": "error", "code": "unauthorized"} и закрытие с кодом 1008;
  • клиент, который не забирает данные 30 секунд, отключается;
  • каждая порция догоняния считается «тяжёлым» запросом (1 в секунду): кончился лимит — отдача ждёт, а не рвётся;
  • после переподключения события после вашего after придут снова — узнавайте повтор по номеру.
JavascriptNode.js (пакет ws)
import WebSocket from "ws"; function connect() { const ws = new WebSocket("wss://partners.zvezdnyy-burabai.edu.kz/ext/v1/stream", { headers: { Authorization: `Bearer ${process.env.ZB_KEY}` }, }); ws.on("message", async (raw) => { const msg = JSON.parse(raw); if (msg.type === "hello") { ws.send(JSON.stringify({ type: "subscribe", after: await loadLastEventId() })); } else if (msg.type === "event") { await handle(msg.event); await saveLastEventId(msg.event.id); } }); ws.on("close", () => setTimeout(connect, 5000)); // переподключиться и продолжить с after ws.on("error", () => {}); // за ошибкой следует close } connect();

Как обрабатывать событие

  1. Уже видели этот id? Пропустите.
  2. Незнакомый type? Пропустите.
  3. Есть object? Обновите у себя запись, если его version новее вашей. Нет object (null) — запись вам больше не видна: пометьте её у себя.
  4. Нужны свежие данные о деньгах — перечитайте бронь по GET.
  5. Сохраните id как обработанный.

Делайте обработку идемпотентной: одно и то же событие, обработанное дважды, не должно, например, дважды отправить родителю SMS «спасибо за оплату».

Если события потерялись у вас

Раз в сутки сверяйтесь: GET /reconcile/bookings отдаёт номер, статус и версию всех броней отдела — перечитайте те, что у вас отличаются (см. Ваша база). Если ваша CRM лежала дольше 90 дней, лента за это время уже очищена — восстановите данные сверкой и списками (GET /bookings, GET /payments с updated_since).

Last modified on