# Вебхуки

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

Что такое события и какие они бывают — в [Событиях](/partners/events). Здесь — как завести вебхук,
проверить, что запрос пришёл именно от лагеря, и правильно отвечать. Нужно право `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`](/api/вебхуки) с `Idempotency-Key`:

```sh
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`:

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

:::danger{title="Секрет показывается один раз"}

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

:::

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

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

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

```sh
curl -X POST https://partners.zvezdnyy-burabai.edu.kz/ext/v1/webhooks/17/test \
  -H "Authorization: Bearer $ZB_KEY"
```

```json
{ "ok": true, "status": 200, "duration_ms": 184, "error": null }
```

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

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

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

```http
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. Проверять подпись

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

<Stepper>

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).

</Stepper>

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

<CodeTabs>

```js title="Node.js (Express)"
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);
```

```python title="Python (Flask)"
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

SECRET = os.environ["ZB_WEBHOOK_SECRET"]  # whsec_…
app = Flask(__name__)


def verify(secret: str, header: str | None, raw_body: bytes) -> bool:
    if not header:
        return False
    parts = [p.strip().split("=", 1) for p in header.split(",")]
    t = next((v for k, v in parts if k == "t"), None)
    sigs = [v for k, v in parts if k == "v1"]
    if t is None or not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    mac = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(mac, v1) for v1 in sigs)


@app.post("/hooks/zvezdnyy")
def zvezdnyy_hook():
    raw = request.get_data()  # сырое тело, до разбора JSON
    if not verify(SECRET, request.headers.get("Zb-Signature"), raw):
        return "", 401
    event = json.loads(raw)
    queue.put(event)  # положить в свою очередь и сразу ответить
    return "", 200
```

```php title="PHP"
<?php
function verify(string $secret, string $header, string $rawBody): bool {
    $t = null; $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($k === 't') $t = $v;
        if ($k === 'v1') $sigs[] = $v;
    }
    if ($t === null || !ctype_digit($t) || abs(time() - (int)$t) > 300) return false;
    $mac = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $v1) {
        if (hash_equals($mac, $v1)) return true;
    }
    return false;
}

$secret  = getenv('ZB_WEBHOOK_SECRET');           // whsec_…
$rawBody = file_get_contents('php://input');      // сырое тело, до json_decode
$header  = $_SERVER['HTTP_ZB_SIGNATURE'] ?? '';

if (!verify($secret, $header, $rawBody)) {
    http_response_code(401);
    exit;
}
$event = json_decode($rawBody, true);
enqueue($event);              // положить в свою очередь и сразу ответить
http_response_code(200);
```

</CodeTabs>

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

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

| Что         | Значение                                                             |
| ----------- | -------------------------------------------------------------------- |
| Секрет      | `whsec_test`                                                         |
| `t`         | `1700000000`                                                         |
| Тело        | `{"id":"1"}`                                                         |
| Ожидаемый `v1` | `11bf4466ea17c3df3fd743af0b435368e16b7a05eb8eced85e8c4670767bdec5` |

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

```sh
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`              | Адрес не виден из интернета, закрыт файрволом или сертификат недействителен |
