# Вебхуки

Платформа сообщает о событиях программы лояльности HTTP-запросами на адреса интегратора (эндпоинты).
Запросы подписаны по спецификации [Standard Webhooks](https://www.standardwebhooks.com), поэтому для
проверки подходят её готовые библиотеки (PHP, Node.js, Python, Go, Java, C#, Ruby, Rust).

## Доставка

- Эндпоинт — https-адрес с публичным IP (адреса внутренних сетей, loopback и link-local запрещены),
  без логина и пароля в URL. Редиректы не выполняются.
- Эндпоинт подписан на типы событий или на все (`*`). Отключённый эндпоинт ничего не получает.
- Доставка **как минимум один раз**: одно и то же событие может прийти повторно — узнавайте повторы по
  заголовку `webhook-id` (он одинаков у всех попыток и у всех эндпоинтов). **Порядок не гарантирован**:
  сравнивайте `timestamp` события, если порядок важен.
- Ответ `2xx` — доставлено. Отвечайте быстро (время ожидания — 10 секунд), тяжёлую обработку делайте
  после ответа.
- Любой другой ответ или ошибка сети — повтор через 5 с, 5 мин, 30 мин, 2 ч, 5 ч, 10 ч, 10 ч, 12 ч,
  12 ч, 12 ч (11 попыток, около 2,6 суток), после чего доставка считается неудавшейся. Её можно
  отправить заново вручную.
- Ответ `410 Gone` отключает эндпоинт.

## Запрос

```http
POST /hooks/loyal HTTP/1.1
Content-Type: application/json
User-Agent: Loyal-Webhooks/1.0
webhook-id: 01929c1e-8e1a-7b4f-9a0e-4c5d7e3f2a10
webhook-timestamp: 1791720000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"type":"receipt.confirmed","version":1,"timestamp":"2026-10-11T12:00:00.000000Z","data":{...}}
```

- `webhook-id` — идентификатор события.
- `webhook-timestamp` — время отправки попытки (Unix, секунды). Отклоняйте запросы старше 5 минут:
  это защищает от повторного воспроизведения.
- `webhook-signature` — одна или несколько подписей через пробел (после ротации секрета — две:
  новым и прежним секретом, сутки). Запрос подлинный, если совпала хотя бы одна.
- Тело: `type` — тип события, `version` — версия его данных, `timestamp` — когда событие произошло,
  `data` — данные события (см. каталог).

## Проверка подписи

Подпись — `v1,` и base64 от HMAC-SHA256 строки `{webhook-id}.{webhook-timestamp}.{тело запроса}` с
ключом — base64-декодированной частью секрета после префикса `whsec_`. Тело берите как есть, до разбора
JSON.

Проще всего — через SDK: `Loyal\Sdk\Webhooks\WebhookVerifier` (PHP) и `WebhookVerifier` из `@loyal/sdk` (TypeScript)
проверяют подпись несколькими секретами (на время ротации), время события и разбирают тело; эталонные случаи —
`sdk/conformance/webhooks.json`. Без SDK, на чистом PHP —
[docs/examples/webhook-receiver.php](../examples/webhook-receiver.php) (проверяется тестами платформы):

```php
$authentic = webhookIsAuthentic(
    secret: getenv('LOYAL_WEBHOOK_SECRET'),
    id: $_SERVER['HTTP_WEBHOOK_ID'],
    timestamp: $_SERVER['HTTP_WEBHOOK_TIMESTAMP'],
    body: file_get_contents('php://input'),
    signatures: $_SERVER['HTTP_WEBHOOK_SIGNATURE'],
);
```

Node.js:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function webhookIsAuthentic(secret, id, timestamp, body, signatures) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();

  return signatures.split(' ').some((signature) => {
    const [version, value] = signature.split(',', 2);
    const actual = Buffer.from(value ?? '', 'base64');
    return version === 'v1' && actual.length === expected.length && timingSafeEqual(actual, expected);
  });
}
```

Секрет эндпоинта (`whsec_...`) показывается один раз — при создании эндпоинта и при ротации.

## Каталог событий

Суммы — целые копейки, баллы — целые минимальные единицы типа баллов программы, время — ISO 8601 UTC.
Персональных данных в событиях нет: только идентификаторы. Новые поля могут появляться в данных
без смены версии; несовместимое изменение — новая версия события.

| Тип | Когда | Данные (`data`) |
|---|---|---|
| `member.enrolled` | Участник зарегистрирован | `member_id`, `program_id`, `registered_at`, `referrer_id` (пригласивший или `null`) |
| `member.tier_changed` | Уровень участника изменился | `member_id`, `program_id`, `from`, `to`, `reason` (`upgrade`, `downgrade`, `manual`), `changed_at` |
| `receipt.confirmed` | Чек подтверждён: баллы списаны и начислены | чек*, `confirmed_at` |
| `receipt.cancelled` | Резерв чека отменён до подтверждения | чек*, `reason` (`cashier`, `expired`) |
| `receipt.voided` | Подтверждённый чек аннулирован целиком | чек* |
| `receipt.returned` | Зарегистрирован возврат по чеку | чек*, `return`: `id`, `number`, `returned_at`, `points_reversed`, `points_restored`, `whole_receipt` |
| `bonus.granted` | Начислен бонус | `award_id`, `member_id`, `program_id`, `kind` (`welcome`, `birthday`, `referrer`, `referee`), `points`, `receipt_id` (покупка, которая принесла бонус, или `null`) |
| `bonus.reversed` | Бонус забран: покупку вернули целиком или аннулировали | `award_id`, `member_id`, `program_id`, `kind`, `points`, `receipt_id` |

\* Чек: `receipt_id`, `program_id`, `terminal_id`, `location_id`, `member_id` (или `null` для анонимного
покупателя), `number`, `business_date`, `purchased_at`, `total`, `discount`, `redeemed_points`,
`points_earned`.

Пример `receipt.confirmed`:

```json
{
  "type": "receipt.confirmed",
  "version": 1,
  "timestamp": "2026-10-11T12:00:00.000000Z",
  "data": {
    "receipt_id": "01929c1e-7d20-7c3a-8f4e-2b9d5a6c1e70",
    "program_id": "01929c1d-11f0-7a2b-9c3d-4e5f6a7b8c9d",
    "terminal_id": "01929c1d-2a40-7f1e-8d2c-3b4a5c6d7e8f",
    "location_id": "01929c1d-2a3f-7e0d-9c1b-2a3b4c5d6e7f",
    "member_id": "01929c1d-5b60-7d4c-8b3a-1f2e3d4c5b6a",
    "number": "1042",
    "business_date": "2026-10-11",
    "purchased_at": "2026-10-11T11:59:58.120000Z",
    "total": 100000,
    "discount": 0,
    "redeemed_points": 300,
    "points_earned": 35,
    "confirmed_at": "2026-10-11T12:00:00.000000Z"
  }
}
```
