> ## Documentation Index
> Fetch the complete documentation index at: https://docs.exode.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Вебхуки

> Получайте события пользователей, курсов, доступов и платежей Exode в своих сервисах в реальном времени

Вебхуки Exode — это **исходящие** HTTP-уведомления: при наступлении события в платформе (регистрация, оплата, прогресс по курсу и т.д.) Exode отправляет `POST`-запрос на ваш URL с данными события.

<Info>
  Доставка идёт почти в реальном времени через очередь с повторными попытками. Приёмник должен быть
  **идемпотентным** и быстро отвечать кодом `200`, `201` или `202` (таймаут ответа — **15 секунд**).
</Info>

## Как это работает

<Steps>
  <Step title="Событие фиксируется">
    Ядро Exode генерирует доменное событие (например, `PaymentCompleted`) с таймстемпом и параметрами инициатора.
  </Step>

  <Step title="Поиск активных эндпоинтов">
    Exode находит активные эндпоинты, подписанные на это событие, на уровне продавца и на системном уровне.
    На одного продавца можно создать **не более 5 эндпоинтов**.
  </Step>

  <Step title="Сборка полезной нагрузки">
    Для события собираются связанные сущности (пользователь, платёж, доступ и т.д.), формируется `data`,
    единый `idempotencyKey` и `timestamp`. Лишние поля вырезаются по строгой схеме (allow-list).
  </Step>

  <Step title="Отправка с подписью и повторы">
    Тело отправляется `POST`-запросом с заголовком `signature` (HMAC-SHA256). Если приёмник не ответил
    `200/201/202` — выполняется до **5 попыток** с нарастающей задержкой.

    <Check>
      Чтобы остановить доставку — переведите эндпоинт в `active = false` или удалите его.
    </Check>
  </Step>
</Steps>

## Формат сообщения

Тело запроса — JSON-объект следующей структуры:

<ResponseField name="event" type="string" required>
  Имя события. Одно из значений раздела [«Поддерживаемые события»](#поддерживаемые-события).
</ResponseField>

<ResponseField name="timestamp" type="string" required>
  Момент возникновения события в формате ISO 8601 (UTC).
</ResponseField>

<ResponseField name="idempotencyKey" type="string" required>
  Единый ключ события. Один и тот же ключ используется для всех эндпоинтов и **сохраняется между
  повторными попытками**. Используйте его для дедупликации.
</ResponseField>

<ResponseField name="data" type="object" required>
  Полезная нагрузка с доменными сущностями. Состав зависит от события — см. ниже.
</ResponseField>

```json Структура тела theme={null}
{
  "event": "PaymentCompleted",
  "timestamp": "2025-01-18T12:44:55.812Z",
  "idempotencyKey": "7qqQL3bE0o8R8d3X",
  "data": { /* depends on the event */ }
}
```

<Warning>
  Заголовок подписи называется буквально `signature` (в нижнем регистре), а не `X-Signature`.
  Подпись считается от **всего тела** запроса, включая `event`, `timestamp` и `idempotencyKey`.
</Warning>

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

В каждом запросе передаётся заголовок `signature` — это `HMAC-SHA256` (hex) от **сырого тела запроса**,
подписанного секретным ключом вашего эндпоинта.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from 'crypto';
  import express from 'express';

  const app = express();

  // Important: use the raw body, not a re-serialized object
  app.post('/webhooks/exode', express.raw({ type: 'application/json' }), (req, res) => {
    const rawBody = req.body.toString('utf8');
    const received = req.header('signature') || '';

    const expected = crypto
      .createHmac('sha256', process.env.EXODE_WEBHOOK_SECRET)
      .update(rawBody)
      .digest('hex');

    const valid =
      received.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));

    if (!valid) {
      return res.status(401).send('invalid signature');
    }

    const payload = JSON.parse(rawBody);
    // TODO: deduplicate by payload.idempotencyKey and handle payload.data

    res.sendStatus(200);
  });
  ```

  ```php PHP theme={null}
  <?php
  $rawBody  = file_get_contents('php://input');
  $received = $_SERVER['HTTP_SIGNATURE'] ?? '';
  $secret   = getenv('EXODE_WEBHOOK_SECRET');

  $expected = hash_hmac('sha256', $rawBody, $secret);

  if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit('invalid signature');
  }

  $payload = json_decode($rawBody, true);
  // TODO: deduplicate by $payload['idempotencyKey'] and handle $payload['data']

  http_response_code(200);
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, os
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.post('/webhooks/exode')
  def exode_webhook():
      raw_body = request.get_data()  # bytes, raw body
      received = request.headers.get('signature', '')
      secret = os.environ['EXODE_WEBHOOK_SECRET'].encode()

      expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, received):
          abort(401)

      payload = request.get_json()
      # TODO: deduplicate by payload['idempotencyKey'] and handle payload['data']
      return '', 200
  ```
</CodeGroup>

<Warning>
  Считайте HMAC именно от **сырого тела** запроса. Повторная сериализация JSON (с другим порядком полей,
  пробелами или экранированием Unicode) даст другую подпись.
</Warning>

## Доставка и повторные попытки

| Параметр                 | Значение                             |
| ------------------------ | ------------------------------------ |
| HTTP-метод               | `POST`                               |
| Content-Type             | `application/json`                   |
| Заголовок подписи        | `signature` (HMAC-SHA256, hex)       |
| Таймаут ответа           | 15 секунд                            |
| Успешные коды            | `200`, `201`, `202`                  |
| Число попыток            | до 5                                 |
| Задержки между попытками | \~11 → 22 → 44 → 88 → 176 минут      |
| Порядок доставки         | не гарантируется                     |
| Дедупликация             | по `idempotencyKey` на вашей стороне |

<Info>
  Любой ответ, кроме `200/201/202`, а также таймаут или сетевая ошибка считаются неуспехом и приводят к
  повторной попытке. После 5 неудачных попыток событие помечается как failed и больше не отправляется.
</Info>

## Управление эндпоинтами

Эндпоинты вебхуков настраиваются в админ-панели школы (раздел настроек). Для каждого эндпоинта задаётся:

* `url` — HTTPS-адрес приёмника (до 255 символов);
* `events` — список событий, на которые он подписан;
* `active` — флаг активности (по умолчанию `true`);
* `note` — произвольная заметка;
* `secretKey` — секрет для проверки подписи, **генерируется автоматически** при создании эндпоинта.

<Info>
  **Где взять секрет для проверки подписи:** `secretKey` создаётся вместе с эндпоинтом и доступен в
  настройках вебхука в админ-панели школы. Скопируйте его и сохраните в переменных окружения вашего
  сервиса (в примерах ниже — `EXODE_WEBHOOK_SECRET`). Если секрет не отображается — запросите его у
  [поддержки](https://t.me/exode_support_biz).
</Info>

<Tip>
  Используйте отдельные эндпоинты для независимых систем — это изолирует сбои и позволяет управлять
  разными наборами событий. На одного продавца — максимум 5 эндпоинтов.
</Tip>

## Поддерживаемые события

Ниже — события, которые реально доставляются, и структура их поля `data`. Вложенные сущности
(`user`, `profile`, `course`, `payment` и др.) описаны в [справочнике объектов](/ru/exode-api/objects/entities/index).

<AccordionGroup>
  <Accordion title="UserSignedUp — регистрация пользователя">
    Триггер: пользователь самостоятельно завершил регистрацию.

    `data`:

    * `user` — объект [пользователя](/ru/exode-api/objects/entities/user).
    * `profile` — карточка профиля (`null`, если не заполнена).
    * `states.utmSignupParams` — объект UTM-меток первичного визита (опционально).
  </Accordion>

  <Accordion title="UserAcquainted — завершён онбординг">
    Триггер: пользователь прошёл онбординг. Структура `data` идентична `UserSignedUp`
    (`user`, `profile`, `states.utmSignupParams`).
  </Accordion>

  <Accordion title="UserTgConnected — привязан Telegram">
    Триггер: пользователь связал аккаунт Telegram.

    `data`:

    * `user` — объект пользователя (с актуальным `tgId`).
    * `profile` — карточка профиля (опционально).
    * `prevTgId` — предыдущий `tgId` для отслеживания перепривязки (`number | null`).
  </Accordion>

  <Accordion title="CourseProgressChanged — изменился прогресс по уроку">
    Триггер: изменился статус урока у пользователя.

    `data`:

    * `user` — [пользователь](/ru/exode-api/objects/entities/user) с профилем.
    * `course` — объект [курса](/ru/exode-api/objects/entities/course).
    * `product` — [продукт](/ru/exode-api/objects/entities/product) курса (опционально).
    * `groups` — массив [групп](/ru/exode-api/objects/entities/group) пользователя в курсе (опционально).
    * `status` — новый статус урока (`CourseProgressLessonStatus`, опционально).
    * `lessonId` — ID урока (опционально).
  </Accordion>

  <Accordion title="CourseCompleted — курс завершён">
    Триггер: пользователь завершил курс.

    `data`:

    * `user` — пользователь с профилем.
    * `course` — объект курса.
    * `product` — продукт курса (опционально).
    * `groups` — массив групп (опционально).
  </Accordion>

  <Accordion title="CourseLessonPracticeCompleted — пройдена практика">
    Триггер: пользователь завершил практическое задание урока.

    `data`:

    * `user` — пользователь с профилем.
    * `course` — курс (опционально).
    * `lesson` — урок (опционально).
    * `practice` — параметры практики (опционально).
    * `attempt` — попытка прохождения с баллами и статусом (опционально).
    * `variantId` — ID варианта (опционально).
  </Accordion>

  <Accordion title="PaymentCompleted — оплата прошла успешно">
    Триггер: платёж успешно завершён.

    `data.payment` — объект [платежа](/ru/exode-api/objects/entities/payment) с полным деревом: `invoice`
    (счёт), `invoice.user` (покупатель с профилем и школой), `invoice.products` (позиции с продуктом/курсом,
    ценой и скидкой), `acquiring` (эквайринг и провайдер, без секретов). Денежные поля — числа.
  </Accordion>

  <Accordion title="ProductEnrolledToFree — выдан бесплатный доступ">
    Триггер: пользователю выдан бесплатный доступ к продукту.

    `data`:

    * `user` — [пользователь](/ru/exode-api/objects/entities/user).
    * `profile` — карточка профиля (опционально).
    * `access` — объект [доступа](/ru/exode-api/objects/entities/product) (опционально).
    * `product` — [продукт](/ru/exode-api/objects/entities/product) (опционально).
    * `course` — [курс](/ru/exode-api/objects/entities/course) (опционально).
  </Accordion>

  <Accordion title="ProductEnrolledViaLms — доступ выдан вручную (LMS)">
    Триггер: доступ к продукту выдан администратором/менеджером через LMS или API.
    Структура `data` идентична `ProductEnrolledToFree` (`user`, `profile`, `access`, `product`, `course`).
  </Accordion>

  <Accordion title="ProductEnrolledViaPayment — доступ выдан после оплаты">
    Триггер: доступ к продукту выдан после успешной оплаты.
    Структура `data` идентична `ProductEnrolledToFree` (`user`, `profile`, `access`, `product`, `course`).
  </Accordion>
</AccordionGroup>

<Note>
  На события `UserSignedIn`, `UserLoggedOut`, `UserJoinedByReferral`, `UserCreatedViaLms`,
  `CourseLessonPracticeDetailedSent`, `CourseLessonPracticeAutoVerifySent`, `ProductRefundCompleted`,
  `ProductAccessSubscriptionEnding7Days` и `ProductAccessSubscriptionEnding1Day` можно подписаться, но
  формальный контракт `data` для них ещё не зафиксирован — состав полезной нагрузки не гарантируется.
  Перед использованием сверьтесь с [поддержкой](https://t.me/exode_support_biz).
</Note>

<Note>
  Событие `SchoolCreated` также доставляется, но только на системном уровне и недоступно для подписки
  продавцом.
</Note>

### Пример: PaymentCompleted

Тело ниже — реальный пример тестовой отправки (синтетические значения, точный состав полей):

```json theme={null}
{
  "event": "PaymentCompleted",
  "timestamp": "2026-07-02T11:16:25.172Z",
  "idempotencyKey": "g9lFWzmF-4BkJaxvFvIEX",
  "data": {
    "payment": {
      "id": 13001,
      "createdAt": "2026-01-01T00:00:00.000Z",
      "updatedAt": "2026-01-01T00:00:00.000Z",
      "archivedAt": null,
      "uuid": "samplePaymentUuid",
      "type": "OneTime",
      "status": "Completed",
      "checkoutPaymentId": "sampleCheckoutPaymentId",
      "checkoutUrl": null,
      "released": true,
      "paidAt": "2026-01-01T00:00:00.000Z",
      "expireAt": null,
      "isCompleted": true,
      "isCanceled": false,
      "meta": {},
      "webhookLogs": [],
      "chargeLogs": [],
      "statusHistoryLogs": [],
      "invoice": {
        "id": 12001,
        "createdAt": "2026-01-01T00:00:00.000Z",
        "updatedAt": "2026-01-01T00:00:00.000Z",
        "archivedAt": null,
        "uuid": "sampleInvoiceUuid",
        "humanId": 1001,
        "type": "Regular",
        "status": "Active",
        "totalAmount": 100,
        "discountAmount": 0,
        "currency": "Usd",
        "expireAt": null,
        "isActive": true,
        "user": {
          "id": 1001,
          "createdAt": "2026-01-01T00:00:00.000Z",
          "updatedAt": "2026-01-01T00:00:00.000Z",
          "archivedAt": null,
          "uuid": "sampleUserUuid",
          "active": true,
          "activated": true,
          "banned": false,
          "alive": true,
          "domain": "id1001",
          "email": "user@example.com",
          "phone": null,
          "tgId": null,
          "vkId": null,
          "appleId": null,
          "extId": null,
          "schoolId": 1,
          "language": "En",
          "timezone": 0,
          "lastOnlineAt": "2026-01-01T00:00:00.000Z",
          "starsBalance": 0,
          "currentTime": "2026-01-01T00:00:00.000Z",
          "isSleepingNow": false,
          "profile": {
            "id": 1001,
            "createdAt": "2026-01-01T00:00:00.000Z",
            "updatedAt": "2026-01-01T00:00:00.000Z",
            "archivedAt": null,
            "userId": 1001,
            "official": false,
            "firstName": "John",
            "lastName": "Doe",
            "fullName": "John Doe",
            "fullNameShort": "John D.",
            "bdate": null,
            "sex": "Ufo",
            "country": null,
            "city": null,
            "role": "Student",
            "status": null,
            "title": null,
            "emojiTitle": null,
            "avatar": {
              "id": 1001,
              "small": "https://storage.example.exode.biz/production/user/1001/sampleAvatar/small/avatar.png",
              "medium": "https://storage.example.exode.biz/production/user/1001/sampleAvatar/medium/avatar.png",
              "maximum": "https://storage.example.exode.biz/production/user/1001/sampleAvatar/avatar.png"
            },
            "titleState": {
              "manualTitle": null,
              "manualEmojiTitle": null,
              "manualNextTitle": null,
              "manualNextEmojiTitle": null,
              "manualExpiredAt": null,
              "locationTitle": null,
              "locationEmojiTitle": null,
              "achievementTitle": null,
              "achievementEmojiTitle": null
            }
          },
          "school": {
            "id": 1,
            "createdAt": "2026-01-01T00:00:00.000Z",
            "updatedAt": "2026-01-01T00:00:00.000Z",
            "archivedAt": null,
            "segment": "Commerce",
            "baseDomain": "school",
            "customDomain": null,
            "accessType": "Public",
            "domainType": "Base",
            "name": "Sample School",
            "description": null,
            "iconUrl": null,
            "active": true,
            "domain": "school",
            "fqdn": "example.exode.biz",
            "publicUrl": "https://example.exode.biz",
            "baseFqdn": "example.exode.biz",
            "isPublic": true,
            "isPrivate": false
          }
        },
        "products": [
          {
            "id": 11001,
            "createdAt": "2026-01-01T00:00:00.000Z",
            "updatedAt": "2026-01-01T00:00:00.000Z",
            "archivedAt": null,
            "originalPrice": 100,
            "totalPrice": 100,
            "discountAmount": 0,
            "discount": null,
            "price": {
              "id": 10001,
              "createdAt": "2026-01-01T00:00:00.000Z",
              "updatedAt": "2026-01-01T00:00:00.000Z",
              "archivedAt": null,
              "mode": "SelfDefinition",
              "type": "OneTime",
              "title": "Sample Price",
              "description": null,
              "amount": 100,
              "previousAmount": null,
              "accessDays": null,
              "infinityAccess": true,
              "active": true,
              "hidden": false,
              "activeFrom": null,
              "activeTo": null,
              "installmentConfig": null,
              "subscriptionConfig": null,
              "isDemo": false,
              "isRecurrent": false,
              "isInstallment": false,
              "isSubscription": false,
              "meta": {}
            },
            "product": {
              "id": 7001,
              "createdAt": "2026-01-01T00:00:00.000Z",
              "updatedAt": "2026-01-01T00:00:00.000Z",
              "archivedAt": null,
              "sellerId": 1,
              "type": "Course",
              "status": "Published",
              "showInCatalog": true,
              "currency": "Usd",
              "publishedAt": "2026-01-01T00:00:00.000Z",
              "saleStartAt": null,
              "saleFinishAt": null,
              "isFree": false,
              "isPublished": true,
              "approves": [],
              "domains": [],
              "course": {
                "id": 2001,
                "createdAt": "2026-01-01T00:00:00.000Z",
                "updatedAt": "2026-01-01T00:00:00.000Z",
                "archivedAt": null,
                "type": "VideoCourse",
                "name": "Sample Course",
                "description": "Sample course description",
                "alias": null,
                "promoVideo": null,
                "order": 0,
                "isBundle": false,
                "tags": [
                  "sample"
                ],
                "seoTags": [],
                "image": {
                  "main": ""
                },
                "settings": {}
              }
            }
          }
        ]
      },
      "acquiring": {
        "id": 1,
        "active": true,
        "uuid": "sampleAcquiringUuid",
        "name": "Sample Acquiring",
        "description": null,
        "hasProviderCommission": false,
        "provider": {
          "id": 1,
          "type": "Card",
          "active": true
        }
      }
    }
  }
}
```

## Диагностика

<AccordionGroup>
  <Accordion title="Повторные попытки">
    Каждая попытка использует одну и ту же нагрузку и `idempotencyKey`. Задержки отсчитываются от предыдущей
    неудачной попытки (≈11 / 22 / 44 / 88 / 176 минут). Верните успешный код, чтобы остановить повторы.
  </Accordion>

  <Accordion title="Временное отключение">
    Установите `active = false`, чтобы приостановить доставку без потери конфигурации и секретного ключа.
  </Accordion>

  <Accordion title="Логирование">
    Логируйте `event`, `timestamp`, `idempotencyKey` и результат проверки подписи — это ускорит сверку с
    нашей стороной при разборе инцидентов.
  </Accordion>
</AccordionGroup>
