Skip to main content
Вебхуки Exode — это исходящие HTTP-уведомления: при наступлении события в платформе (регистрация, оплата, прогресс по курсу и т.д.) Exode отправляет POST-запрос на ваш URL с данными события.
Доставка идёт почти в реальном времени через очередь с повторными попытками. Приёмник должен быть идемпотентным и быстро отвечать кодом 200, 201 или 202 (таймаут ответа — 15 секунд).

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

1

Событие фиксируется

Ядро Exode генерирует доменное событие (например, PaymentCompleted) с таймстемпом и параметрами инициатора.
2

Поиск активных эндпоинтов

Exode находит активные эндпоинты, подписанные на это событие, на уровне продавца и на системном уровне. На одного продавца можно создать не более 5 эндпоинтов.
3

Сборка полезной нагрузки

Для события собираются связанные сущности (пользователь, платёж, доступ и т.д.), формируется data, единый idempotencyKey и timestamp. Лишние поля вырезаются по строгой схеме (allow-list).
4

Отправка с подписью и повторы

Тело отправляется POST-запросом с заголовком signature (HMAC-SHA256). Если приёмник не ответил 200/201/202 — выполняется до 5 попыток с нарастающей задержкой.
Чтобы остановить доставку — переведите эндпоинт в active = false или удалите его.

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

Тело запроса — JSON-объект следующей структуры:
event
string
required
Имя события. Одно из значений раздела «Поддерживаемые события».
timestamp
string
required
Момент возникновения события в формате ISO 8601 (UTC).
idempotencyKey
string
required
Единый ключ события. Один и тот же ключ используется для всех эндпоинтов и сохраняется между повторными попытками. Используйте его для дедупликации.
data
object
required
Полезная нагрузка с доменными сущностями. Состав зависит от события — см. ниже.
Структура тела
{
  "event": "PaymentCompleted",
  "timestamp": "2025-01-18T12:44:55.812Z",
  "idempotencyKey": "7qqQL3bE0o8R8d3X",
  "data": { /* depends on the event */ }
}
Заголовок подписи называется буквально signature (в нижнем регистре), а не X-Signature. Подпись считается от всего тела запроса, включая event, timestamp и idempotencyKey.

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

В каждом запросе передаётся заголовок signature — это HMAC-SHA256 (hex) от сырого тела запроса, подписанного секретным ключом вашего эндпоинта.
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);
});
Считайте HMAC именно от сырого тела запроса. Повторная сериализация JSON (с другим порядком полей, пробелами или экранированием Unicode) даст другую подпись.

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

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

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

Эндпоинты вебхуков настраиваются в админ-панели школы (раздел настроек). Для каждого эндпоинта задаётся:
  • url — HTTPS-адрес приёмника (до 255 символов);
  • events — список событий, на которые он подписан;
  • active — флаг активности (по умолчанию true);
  • note — произвольная заметка;
  • secretKey — секрет для проверки подписи, генерируется автоматически при создании эндпоинта.
Где взять секрет для проверки подписи: secretKey создаётся вместе с эндпоинтом и доступен в настройках вебхука в админ-панели школы. Скопируйте его и сохраните в переменных окружения вашего сервиса (в примерах ниже — EXODE_WEBHOOK_SECRET). Если секрет не отображается — запросите его у поддержки.
Используйте отдельные эндпоинты для независимых систем — это изолирует сбои и позволяет управлять разными наборами событий. На одного продавца — максимум 5 эндпоинтов.

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

Ниже — события, которые реально доставляются, и структура их поля data. Вложенные сущности (user, profile, course, payment и др.) описаны в справочнике объектов.
Триггер: пользователь самостоятельно завершил регистрацию.data:
  • user — объект пользователя.
  • profile — карточка профиля (null, если не заполнена).
  • states.utmSignupParams — объект UTM-меток первичного визита (опционально).
Триггер: пользователь прошёл онбординг. Структура data идентична UserSignedUp (user, profile, states.utmSignupParams).
Триггер: пользователь связал аккаунт Telegram.data:
  • user — объект пользователя (с актуальным tgId).
  • profile — карточка профиля (опционально).
  • prevTgId — предыдущий tgId для отслеживания перепривязки (number | null).
Триггер: изменился статус урока у пользователя.data:
  • userпользователь с профилем.
  • course — объект курса.
  • productпродукт курса (опционально).
  • groups — массив групп пользователя в курсе (опционально).
  • status — новый статус урока (CourseProgressLessonStatus, опционально).
  • lessonId — ID урока (опционально).
Триггер: пользователь завершил курс.data:
  • user — пользователь с профилем.
  • course — объект курса.
  • product — продукт курса (опционально).
  • groups — массив групп (опционально).
Триггер: пользователь завершил практическое задание урока.data:
  • user — пользователь с профилем.
  • course — курс (опционально).
  • lesson — урок (опционально).
  • practice — параметры практики (опционально).
  • attempt — попытка прохождения с баллами и статусом (опционально).
  • variantId — ID варианта (опционально).
Триггер: платёж успешно завершён.data.payment — объект платежа с полным деревом: invoice (счёт), invoice.user (покупатель с профилем и школой), invoice.products (позиции с продуктом/курсом, ценой и скидкой), acquiring (эквайринг и провайдер, без секретов). Денежные поля — числа.
Триггер: пользователю выдан бесплатный доступ к продукту.data:
Триггер: доступ к продукту выдан администратором/менеджером через LMS или API. Структура data идентична ProductEnrolledToFree (user, profile, access, product, course).
Триггер: доступ к продукту выдан после успешной оплаты. Структура data идентична ProductEnrolledToFree (user, profile, access, product, course).
На события UserSignedIn, UserLoggedOut, UserJoinedByReferral, UserCreatedViaLms, CourseLessonPracticeDetailedSent, CourseLessonPracticeAutoVerifySent, ProductRefundCompleted, ProductAccessSubscriptionEnding7Days и ProductAccessSubscriptionEnding1Day можно подписаться, но формальный контракт data для них ещё не зафиксирован — состав полезной нагрузки не гарантируется. Перед использованием сверьтесь с поддержкой.
Событие SchoolCreated также доставляется, но только на системном уровне и недоступно для подписки продавцом.

Пример: PaymentCompleted

Тело ниже — реальный пример тестовой отправки (синтетические значения, точный состав полей):
{
  "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": "[email protected]",
          "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
        }
      }
    }
  }
}

Диагностика

Каждая попытка использует одну и ту же нагрузку и idempotencyKey. Задержки отсчитываются от предыдущей неудачной попытки (≈11 / 22 / 44 / 88 / 176 минут). Верните успешный код, чтобы остановить повторы.
Установите active = false, чтобы приостановить доставку без потери конфигурации и секретного ключа.
Логируйте event, timestamp, idempotencyKey и результат проверки подписи — это ускорит сверку с нашей стороной при разборе инцидентов.