Справочник по 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-событий: находки парсинга с severityerrorпо всем файлам (файл, который не удалось прочитать вовсе, считается за одну) и находки с severitywarning;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; словарь плейсхолдеров —
в справочнике плейсхолдеров и доставок.