Перейти к содержанию

Справочник по webhooks

Эта страница — контракт между Expecto и потребителем webhook'ов: какие события приходят, как выглядит envelope, как проверять подпись и как вести себя при ошибках. Если ты настраиваешь endpoint'ы из UI, начни с гайда для org admins, а эту страницу отдай тому, кто пишет приёмник.

Expecto следует HTTP-конвенции подписи Standard Webhooks: версионированный HMAC-SHA256 над id.timestamp.body, передаётся в заголовке webhook-signature.

События

События жизненного цикла сессии, авто-фетча контекста, комментариев и джоб плюс синтетический webhook.ping. Каждое событие срабатывает при переходе стадии, сразу после того как Expecto закоммитил новое состояние — БД консистентна с тем, что описано в payload'е.

Type Триггер
session.created Создана сессия.
session.uploaded Загружен файл для сессии. Срабатывает один раз на каждый загруженный файл.
session.parsed Все обязательные файлы разобраны без ошибок — технический результат. Не ждёт сбора контекста; несёт parse_error_count / parse_warning_count.
session.transformed Все трансформеры выдали output без ошибок.
session.checked Бизнес-правила проекта отработали без ошибок.
session.confirmed Отправитель подтвердил данные.
session.approved Approver дал финальное согласие (только в режиме dual).
session.exported Экспортный пакет собран и сохранён.
session.failed Прогон закончился без session.checked: упала стадия или прогон остановлен на контекстном гейте — после парсинга, до трансформации (сбор контекста упал или истёк таймаут, контекст не прошёл проверку). Payload содержит data.stage, data.reason, data.message и data.phase.
session.context_fetch_started Авто-фетч контекста поставлен в очередь при создании сессии (у проекта есть интеграционные context-источники).
session.context_fetch_completed Авто-фетч контекста успешно завершился.
session.context_fetch_failed Авто-фетч контекста упал (пайплайн заблокирован до повторного запуска).
session.comment_added Пользователь добавил комментарий к сессии.
job.updated Асинхронная джоба (pipeline/export/import) сменила статус. Payload: data.{job_id, job_type, status, session_id}. Подписка только на уровне организации.
webhook.ping Синтетический ping по запросу из UI/API.

session.approved намеренно пропускается при single approval mode проекта — session.confirmed сразу переходит к экспорту.

Форма envelope

Каждая доставка — это один JSON-объект следующей формы:

{
  "id": "618d0e8d-22c7-4a4a-9cdb-20fc9510921f",
  "type": "session.exported",
  "timestamp": "2026-06-09T13:30:11.859321+00:00",
  "data": {
    "session_id": "7c3a76c0-6a00-44a5-a0ad-9093e6d219e1",
    "organization_id": "6042680b-e742-4628-8b62-3d16c4e6e5a9",
    "project_id": "201569bb-5171-4ad0-bfc7-ab8740d6bf37",
    "data_contract_id": "f46976f3-700d-49c8-979d-e0ffbeb1ff04",
    "data_provider_id": null,
    "period": "2026-06",
    "status": "exported",
    "health": "completed",
    "verdict": "passed",
    "phase": null,
    "parse_error_count": null,
    "parse_warning_count": null,
    "occurred_at": "2026-06-09T13:30:11.859321+00:00",
    "export": {
      "package_name": "Webhook_E2E_demo_2026-06_20260609_133011.zip",
      "artifacts": [
        {
          "filename": "echo.csv",
          "role": "echo",
          "row_count": 3,
          "sha256": "8d8c559fe7f3f9f26919a438bde92fddf03099630559c3eedfd92fce0a7bf3c1",
          "size_bytes": 44
        }
      ]
    }
  }
}
  • id — UUID доставки. Стабилен между retry'ями одной попытки; новый на replay. Используй как ключ идемпотентности на своей стороне.
  • type — один из перечисленных типов выше.
  • timestamp — ISO-8601 логического времени события (когда Expecto закоммитил состояние). Совпадает с data.occurred_at.
  • data — payload, зависящий от типа. Идентификаторные поля (session_id, organization_id, project_id, data_contract_id, data_provider_id, period, status, health, verdict, phase, parse_error_count, parse_warning_count, occurred_at) есть в каждом session-событии. Три из них легко спутать:

    • status — как далеко сессия прошла по пайплайну (created … exported).
    • verdict — результат автоматических проверок: passed, warnings, failed либо null, пока результата нет. Именно на это поле стоит реагировать интеграции: оно только про исход и стабильно, предупреждение не перекрывается более поздней стадией.
    • health — «светофор» для дашборда (created, in_progress, blocked, attention, ready, completed). Смешивает прогресс и исход и может пополняться под нужды UI — в интеграциях лучше verdict.
    • phase — какую половину двухфазного потока сообщает событие: technical (парсинг — session.parsed, session.failed со stage = parse), context (контекстный гейт — session.failed со stage = context_fetch / context_validation), content (всё после гейта — session.transformed, session.checked, session.failed со stage = transform / validation_gating / validate / check), null у остальных событий. Маппьте на picklist, если принимающая система ведёт технический и содержательный результат раздельно.
    • parse_error_count / parse_warning_count — целые числа у technical-событий: находки парсинга с severity error по всем файлам (файл, который не удалось прочитать вовсе, считается за одну) и находки с severity warning; null у остальных событий.

Type-specific: - session.exported.data.export.{package_name, artifacts} — метаданные пакета. В artifacts[*] — filename, role, sha256, row_count, size_bytes. Нет download URL — пакет забирается через авторизованный API: GET /api/sessions/{session_id}/export/download со своим API key. - session.failed.data.{stage, reason, message} — stage это context_validation, context_fetch, parse, transform, validate, validation_gating или check; reason — короткий код ошибки (например context_invalid, context_fetch_failed, context_fetch_timeout, parse_error, transform_error, system_error, blocking_violations, check_error); message — человекочитаемое пояснение или null. validation_gating / blocking_violations означает, что валидация сущностей нашла нарушения на уровне блокирующей строгости проекта или выше, и прогон закончился до проверки бизнес-правил — данные не прошли, verdict = failed. context_fetch / context_validation означает, что прогон остановлен на контекстном гейте — после парсинга, до трансформации: автоматический сбор контекста упал (или истёк таймаут, пока прогон его ждал), либо объединённый контекст сессии не соответствует схеме проекта при строгой проверке. Парсинг уже прошёл (session.parsed пришёл раньше), дальше ничего не выполнялось, содержательных находок для выгрузки нет, verdict = null, если парсинг не дал предупреждений; message называет упавшие источники контекста и их ошибки (например Halted: context fetch is failed (sap_prev_balance: BuiltAPI rejected the connection credentials (401))). Незавершённый сбор события больше не даёт: прогон ждёт его и продолжается сам. Любой способ завершить прогон без session.checked даёт session.failed; подписчику никогда не нужно угадывать остановку по тишине. - webhook.ping.data.{sent_at, sent_by_user_id} — для проверки связности.

HTTP-заголовки

Каждый POST несёт:

Header Пример Значение
webhook-id 618d0e8d-22c7-4a4a-9cdb-20fc9510921f UUID доставки. Стабилен между retry, новый на replay.
webhook-timestamp 1781008807 Unix-секунды на момент подписи body.
webhook-signature v1,WtkBkfcIcZB3iHOB+wE/jCeCnIkMjNw7BSiaWmHj7Kg= Линия подписи. version,base64-HMAC.
content-type application/json Body всегда JSON.
user-agent Expecto-Webhooks/0.0.1 Стабильный префикс; версия может расти.

Body всегда UTF-8 JSON. Expecto не следует по redirect'ам.

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

Подписываемая строка:

<webhook-id>.<webhook-timestamp>.<body>

— где <body> это точные байты request body, а ключ подписи — это ключ whsec_… в открытом виде, который показали admin'у при создании endpoint'а (или ротации).

Значение заголовка — v1,<base64-стандартный-HMAC-SHA256>. В будущем несколько подписей могут идти через пробел (например при ротации с overlap'ом); отвергай request, если хотя бы одна не верифицируется.

Отвергай любой request, у которого webhook-timestamp отличается от текущего времени больше чем на 5 минут (webhook_signature_tolerance_seconds, по умолчанию 300). Это блокирует наивный replay захваченных payload'ов.

Python (FastAPI / Flask / Django)

import base64
import hashlib
import hmac
import time

WEBHOOK_TOLERANCE_SECONDS = 5 * 60


def verify(secret: str, headers: dict, body: bytes) -> dict:
    webhook_id = headers["webhook-id"]
    webhook_timestamp = headers["webhook-timestamp"]
    webhook_signature = headers["webhook-signature"]

    if abs(int(time.time()) - int(webhook_timestamp)) > WEBHOOK_TOLERANCE_SECONDS:
        raise ValueError("Timestamp вне окна толерантности")

    # secret имеет вид "whsec_<base64>" — Standard Webhooks НЕ декодирует
    # префикс из base64; Expecto подписывает байтами открытого ключа.
    secret_bytes = secret.encode("utf-8")

    signed_payload = f"{webhook_id}.{webhook_timestamp}.{body.decode('utf-8')}".encode("utf-8")
    expected = base64.b64encode(hmac.new(secret_bytes, signed_payload, hashlib.sha256).digest()).decode("ascii")

    for sig in webhook_signature.split(" "):
        version, _, value = sig.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            import json
            return json.loads(body)

    raise ValueError("Подходящая подпись не найдена")

Node.js / Express

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];

  if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SECONDS) {
    throw new Error("Timestamp вне окна толерантности");
  }

  const signed = `${id}.${ts}.${rawBody.toString("utf-8")}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("base64");

  for (const sig of sigHeader.split(" ")) {
    const [version, value] = sig.split(",");
    if (version === "v1" && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected))) {
      return JSON.parse(rawBody.toString("utf-8"));
    }
  }
  throw new Error("Подходящая подпись не найдена");
}

Используй сырые байты

Большинство веб-фреймворков парсят JSON до того, как твой handler запустится. Захвати сырой request body до парсинга — пересериализация распарсенного объекта даст другие байты, и подпись не сойдётся. В Express нужен express.raw({type: 'application/json'}); в FastAPI нужен корутин request.body() до request.json(); в Django — request.body до request.POST.

Опциональный auth header

Endpoint может нести опциональную пару (auth_header_name, auth_header_value). Если она задана, каждый исходящий запрос — включая ping, retry и replay — добавляет настроенный заголовок поверх Standard Webhooks signing-заголовков. Используй, если твой приёмник стоит за reverse proxy или API gateway, требующим Authorization: Bearer …, X-API-Key: … или подобного.

Значение шифруется тем же Fernet-ключом, что и подписной секрет (webhook_signing_key), и расшифровывается только перед отправкой. Read-API возвращают имя заголовка и булев auth_header_configured; значение в открытом виде никогда не возвращается после создания.

Имя заголовка должно матчить ^[A-Za-z][A-Za-z0-9_-]{0,63}$ и не должно конфликтовать с заголовками, которые система ставит сама (webhook-id, webhook-timestamp, webhook-signature, host, content-length, content-type, user-agent).

Auth header дополняет проверку подписи, не заменяет её. Подпись по-прежнему аутентифицирует отправителя. Приёмнику следует и проверять подпись, и (независимо) удовлетворять требованиям своего прокси.

Retry и тайм-ауты

Expecto считает любой HTTP-ответ вне 2xx ошибкой (включая 5xx, 3xx redirects и транспортные сбои). При ошибке планируется retry с экспоненциальным backoff:

  • Базовая задержка: 30 с (webhook_backoff_base_seconds)
  • Cap: 1 ч (webhook_backoff_cap_seconds)
  • Max попыток: 6 (webhook_max_attempts)
  • Jitter per-(delivery, attempt) — чтобы retry'и многих endpoint'ов не синхронизировались.
  • Request timeout: 30 с (webhook_request_timeout_seconds).
  • Response excerpt: Expecto читает максимум 4 KiB ответа и сохраняет на строке доставки для диагностики. Держи ответ маленьким.

После 6-й неудачной попытки доставка уходит в dead-letter. Счётчик consecutive_failures endpoint'а растёт; одна успешная доставка сбрасывает его в ноль. После 10 подряд dead-letter'ов endpoint автоматически отключается и перестаёт получать события, пока admin не включит обратно. См. гайд org admin'a.

Идемпотентность

Используй webhook-id как ключ идемпотентности на своей стороне. Retry'и переиспользуют тот же webhook-id; replay из UI выдаёт новый webhook-id (и перепподписывает body, чтобы проверка подписи прошла) — это намеренно, чтобы можно было reшаpить доставку без молчаливой дедупликации на приёмнике.

Если твоему потребителю нужен exactly-once, храни (endpoint_id, webhook-id) и отвергай дубли. Worker Expecto уже идемпотентен у себя: доставка со статусом succeeded короткозамыкается и не пересылается.

Операционные замечания

  • Ping (webhook.ping) не несёт бизнес-данных — используй только для проверки связности и подписи.
  • Replay события session.exported пересылает те же метаданные артефактов; сам пакет не меняется. Если уже скачал и обработал, не нужно перекачивать.
  • webhook-id и data.id — у не-ping событий envelope-уровневый id это id доставки (меняется на replay); внутренние data.session_id / data.project_id это id доменных сущностей и не меняются. Храни доставки по (endpoint_id, webhook-id); храни бизнес-состояние по data.session_id.

Проверка через UI

Самый быстрый способ убедиться, что приёмник настроен правильно — Send ping во вкладке Webhooks у org admin'a. Expecto подписывает envelope webhook.ping текущим ключом endpoint'а, POSTит его и показывает код ответа и выдержку из ответа в логе доставок в течение секунд. Если проверка падает на стороне потребителя, верни 400 с короткой причиной в body — она ляжет в last_response_excerpt и сделает причину видимой в UI.

Исходящие привязки (запись во внешние платформы)

Webhook-эндпоинты доставляют подписанный envelope на ваш HTTP-приёмник. Когда получатель — сторонняя платформа со своим API (сегодня это Salesforce), используйте исходящую привязку проекта: Expecto сам аутентифицируется на платформе и создаёт запись по адаптер-специфичному маппингу вместо отправки универсального envelope. Доставки привязок используют ту же машинерию — тот же цикл retry/backoff, dead-letter, replay и история (GET /api/webhook-deliveries, фильтр по connection_id).

Полное руководство — подготовка организации Salesforce, создание подключения, маппинг с required_fields и value_maps, какие события несут run_id и verdict, семантика ошибок и диагностика — в разделе Интеграции › Salesforce; словарь плейсхолдеров — в справочнике плейсхолдеров и доставок.