# События

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

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

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

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

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

О вебхуках — отдельная страница: [Вебхуки](/partners/webhooks).

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

```json
{
  "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` |

:::caution{title="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](/partners/kaspi-invoices#состояния-счёта)).

**Платежи** (`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` — оставшаяся                  |

Подробнее — в [Ваша база](/partners/your-base).

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

**Возвраты** (в подготовке, см. [Возвраты](/partners/refunds)): `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 секунд и спросите снова.

```sh
curl "https://partners.zvezdnyy-burabai.edu.kz/ext/v1/events?after=1000&limit=100" \
  -H "Authorization: Bearer $ZB_KEY"
```

```json
{
  "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`.

<CodeTabs>

```js title="JavaScript"
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);
}
```

```python title="Python"
import os
import time
import requests

after = load_last_event_id()  # из своей базы; в начале — "0"
while True:
    res = requests.get(
        "https://partners.zvezdnyy-burabai.edu.kz/ext/v1/events",
        headers={"Authorization": f"Bearer {os.environ['ZB_KEY']}"},
        params={"after": after},
        timeout=30,
    )
    if res.status_code == 429:
        time.sleep(int(res.headers.get("Retry-After", 5)))
        continue
    page = res.json()
    for event in page["items"]:
        handle(event)  # повтор узнаётся по event["id"]
        save_last_event_id(event["id"])
    after = page["next_after"]
    if not page["has_more"]:
        time.sleep(15)
```

```php title="PHP"
$after = loadLastEventId(); // из своей базы; в начале — "0"
while (true) {
    $ch = curl_init('https://partners.zvezdnyy-burabai.edu.kz/ext/v1/events?after=' . urlencode($after));
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('ZB_KEY')],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);
    $raw = curl_exec($ch);
    if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 429) { sleep(5); continue; }
    $page = json_decode($raw, true);
    foreach ($page['items'] as $event) {
        handle($event); // повтор узнаётся по $event['id']
        saveLastEventId($event['id']);
    }
    $after = $page['next_after'];
    if (!$page['has_more']) sleep(15);
}
```

</CodeTabs>

## Веб-сокет

Адрес — `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` придут снова — узнавайте повтор по номеру.

```js title="Node.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`](/api/отчеты) отдаёт номер, статус и версию
всех броней отдела — перечитайте те, что у вас отличаются (см.
[Ваша база](/partners/your-base#сверка-раз-в-сутки)). Если ваша CRM лежала дольше 90 дней, лента
за это время уже очищена — восстановите данные сверкой и списками (`GET /bookings`, `GET /payments`
с `updated_since`).
