#Вебхуки (версия 2)
Статус: Доступно. Исходящие вебхуки прежней версии (v1) в публичный контракт не входят и будут заменены.
Назначение: получать события платформы на ваш HTTPS-адрес вместо опроса API: новое сообщение, лид, готовая статья, отчёт стратега.
Доступ: область webhooks:manage для управления подписками. Платно: нет. ПДн: по умолчанию нет (события «тонкие»); флаг подписки include_pii включает ПДн в событиях лидов, контактов и диалогов и требует у интеграции областей чтения этих ресурсов. Содержимое событий, запрошенное по ключу, требует областей соответствующих ресурсов.
Примеры JSON иллюстративны: имена необязательных полей уточнятся в OpenAPI.
#Как это работает
- Вы создаёте подписку: адрес
https://…и список событий. - Платформа записывает событие в надёжную очередь одновременно с изменением данных (событие не теряется, даже если доставка не удалась).
- Воркер доставляет событие
POST-запросом на ваш адрес с подписью. - Вы проверяете подпись, быстро отвечаете
2xxи обрабатываете событие у себя. - При неудаче платформа повторяет доставку: до 8 повторов с нарастающими паузами (расписание). Доставка, не принятая и после восьмого повтора, становится «мёртвым письмом» (
state: dead): она остаётся в журнале на 30 суток. Автоматической переотправки нет: недостающее забирайте из API по идентификатору ресурса.
#Управление подписками
| Метод и путь | Назначение | Область |
|---|---|---|
POST /webhooks | Создать подписку | webhooks:manage |
GET /webhooks | Список подписок вашей интеграции | webhooks:manage |
GET /webhooks/{id} | Одна подписка | webhooks:manage |
PATCH /webhooks/{id} | Изменить адрес, события, include_pii, description, status (active или paused) | webhooks:manage |
DELETE /webhooks/{id} | Удалить подписку | webhooks:manage |
POST /webhooks/{id}/test | Отправить тестовое событие webhook.test | webhooks:manage |
POST /webhooks/{id}/rotate-secret | Заменить секрет подписи (с перекрытием 24 часа) | webhooks:manage |
GET /webhooks/{id}/deliveries | Журнал доставок | webhooks:manage |
#Создание подписки
POST /webhooks, заголовки: Authorization, Content-Type: application/json, Idempotency-Key.
| Параметр | Тип | Обязательно | Ограничения | Пример |
|---|---|---|---|---|
url | строка | да | https, порт 443, без IP-адреса вместо имени, без переадресаций | https://hooks.example.com/sapport |
events | массив строк | да | значения из списка событий | ["lead.created", "message.created"] |
include_pii | логический | нет | по умолчанию false. true разрешён только если среди events лишь события lead.*, contact.*, conversation.*; у интеграции должны быть leads:read и (для событий диалогов) conversations:read. См. include_pii | false |
description | строка | нет | до 200 знаков, для вашего удобства | CRM, боевая |
curl -sS -X POST "$SAPPORT_BASE_URL/webhooks" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6b1f0b8e-0f55-4a39-9b0b-1c1d6a1d9a01" \
-d '{"url": "https://hooks.example.com/sapport", "events": ["lead.created", "article.ready"]}'
Ответ 201 (иллюстративный):
{
"id": "whk_EXAMPLE01",
"url": "https://hooks.example.com/sapport",
"events": ["lead.created", "article.ready"],
"include_pii": false,
"description": null,
"status": "active",
"secret": "whsec_EXAMPLE_NOT_A_REAL_SECRET",
"created_at": "2026-10-11T08:15:30Z",
"updated_at": "2026-10-11T08:15:30Z",
"secret_rotated_at": null,
"previous_secret_expires_at": null
}
Секрет подписи сохраните сразу: он показывается один раз — при создании подписки и при ротации — и в дальнейшем не возвращается при чтении подписки. Секрет хранится на стороне платформы в зашифрованном виде. При повторе запроса с тем же Idempotency-Key ответ тот же, но secret равен null: потеряли секрет — выполните rotate-secret. На интеграцию приходится не больше 10 подписок (иначе 409 invalid_state); подписки видны только той интеграции, которая их создала.
#Требования к адресу
Адрес проверяется защитой от SSRF при создании и при каждом соединении:
- схема
https, порт443; - имя хоста, а не IP-адрес; адреса во внутренних и служебных диапазонах отклоняются (DNS проверяется в момент соединения);
- переадресации (
3xx) не выполняются — ответ3xxсчитается неудачей; - сертификат должен быть действительным.
Нарушение при создании — 422 validation_failed.
#События
События делятся на группы. Все они доставляются подписчикам, которые указали их в events.
#Диалоги и ИИ-продажник
| Событие | Когда | Содержимое при include_pii: true |
|---|---|---|
conversation.created | Создан диалог | да (ПДн) |
message.created | В диалоге появилось сообщение (клиента, ИИ или оператора). Для сообщения клиента в data есть ai_reply: {status, reason?} | нет: текст сообщения в событие не включается, читайте его через API |
conversation.handoff_requested | Запрошена передача диалога оператору. В data — lead_id, если у диалога есть лид | да |
conversation.summary_ready | Готова сводка диалога | да |
Событие conversation.handoff_requested единственное для передачи оператору: события handoff.requested (прежняя версия вебхуков) и lead.handoff_requested в v2 не используются.
#Лиды и CRM
| Событие | Когда | Содержимое при include_pii: true |
|---|---|---|
lead.created | Создан лид | да (контакт — ПДн) |
lead.updated | Изменились поля лида (в событии: изменённые поля и версия) | да |
lead.scored | Лид оценён: оценка, уровень, причина, флаги | да |
lead.status_changed | Изменился статус лида | да |
lead.action_requested | Агент просит внешнюю CRM выполнить действие (сменить статус, назначить, поставить задачу, отправить дожим, передать оператору) — с причиной и доказательством | да |
task.created | Создана задача | нет |
task.completed | Задача закрыта | нет |
contact.merged | Контакты объединены | да |
crm.mode_changed | Арендатор переключил режим CRM | нет |
Подробнее о событиях CRM и режимах — crm/01-modes.md, crm/04-agent-actions.md.
#Статьи
| Событие | Когда |
|---|---|
article.ready | Статья готова (сгенерирована, прошла проверку). Если вы публикуете сами — забирайте статью по этому событию |
article.published | Публикация подтверждена (получен адрес опубликованной страницы) |
article.generation.failed | Генерация статьи завершилась ошибкой |
#Стратег
| Событие | Когда |
|---|---|
strategist.report.ready | Готов новый отчёт стратега |
strategist.turn.completed | Завершён ход разговора со стратегом |
#Служебные
| Событие | Когда |
|---|---|
operation.completed | Завершилась асинхронная операция (succeeded, failed, canceled). В data — конверт operation |
api_key.expiring | Срок действия ключа истекает: событие приходит за 30 и за 7 суток до срока |
webhook.test | Тестовая доставка по запросу POST /webhooks/{id}/test; livemode: false |
Список может расширяться. Обрабатывайте неизвестные типы событий как «принято, игнорировать» (отвечайте 2xx), иначе новые события будут копиться в повторах.
#Что приходит
#Заголовки доставки
| Заголовок | Значение |
|---|---|
Content-Type | application/json |
Sapport-Event-Id | Уникальный идентификатор события (одинаков во всех повторах одной доставки) |
Sapport-Event-Type | Тип события, например lead.created |
Sapport-Signature | Подпись: t=<unix-время>,v1=<hex>; в период ротации секрета — два значения v1 |
#Тело: «тонкая» нагрузка по умолчанию
По умолчанию событие содержит только идентификаторы и тип: никаких ПДн. Недостающее вы запрашиваете через API со своим ключом. Так персональные данные не отправляются на произвольный адрес без ключа.
Конверт события одинаков для всех типов:
{
"id": "evt_EXAMPLE01",
"type": "lead.created",
"api_version": "2026-10-01",
"created_at": "2026-10-11T08:15:30Z",
"livemode": true,
"data": {
"lead_id": "lead_EXAMPLE01",
"contact_id": "ctc_EXAMPLE01",
"conversation_id": "cnv_EXAMPLE01",
"version": 1
}
}
| Поле | Тип | Описание |
|---|---|---|
id | строка | Идентификатор события (равен Sapport-Event-Id) |
type | строка | Тип события (равен Sapport-Event-Type) |
api_version | строка | Версия схемы события: 2026-10-01 |
created_at | строка | Время события, UTC |
livemode | логический | true для боевых данных; false для тестового события webhook.test и данных песочницы |
data | объект | Идентификаторы ресурсов. Для событий лида содержит version — версию самого лида |
После получения события запросите ресурс: GET /leads/{lead_id} (область leads:read).
#include_pii — полная нагрузка
Для адресов, которым арендатор доверил ПДн (например, своей CRM), у подписки включается флаг include_pii: true: содержимое (контакт, поля лида) приходит прямо в событии.
| Условие | Значение |
|---|---|
| События | Только lead.*, contact.*, conversation.*. Для остальных типов флаг не применяется; message.created текст сообщения не содержит никогда |
| Область | У интеграции должны быть leads:read и/или conversations:read — по типам выбранных событий |
| Журналы | Включение отражается в журнале раскрытий; каждая такая доставка попадает в журнал выдач ПДн (Ячейки и данные) |
Включайте
include_piiтолько для адресов, которым вы вправе передавать ПДн своих клиентов. В ячейке RU для этого требуются обязательный список IP интеграции, указание страны размещения принимающей системы и основания передачи; ПДн передаются только получателям в РФ, при иной странеinclude_piiдля интеграции не выдаётся (403 pii_transfer_not_allowed, Ячейки и данные).
#Подпись
Каждая доставка подписана. Заголовок:
Sapport-Signature: t=1760170530,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
| Часть | Смысл |
|---|---|
t | Время подписи (секунды Unix, UTC) |
v1 | HMAC-SHA256 в шестнадцатеричной записи над строкой t + "." + тело с вашим секретом подписи |
Что подписывается. Тело запроса целиком в том виде, в каком оно пришло (байты, до разбора JSON). Тело содержит идентификатор, тип и версию схемы события, поэтому подпись защищает и от подмены содержимого, и от подмены типа события.
#Алгоритм проверки
- Получите сырое тело запроса (не пересериализованный JSON).
- Разберите
Sapport-Signature: значенияtи всеv1. - Вычислите
HMAC_SHA256(секрет, t + "." + тело)в hex. - Сравните с каждым
v1за постоянное время. Достаточно совпадения с любым. - Проверьте свежесть:
|сейчас − t|не больше допуска (рекомендуется 5 минут). Иначе отклоните как повтор. - Проверьте, что
Sapport-Event-Idещё не обработан (защита от повтора и дублей). - Только после этого разбирайте JSON.
Если подпись не совпала — ответьте 4xx (например, 400) и не обрабатывайте тело.
#Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRETS = [process.env.SAPPORT_WEBHOOK_SECRET]; // при ротации — оба секрета
const TOLERANCE_SEC = 300;
const seen = new Map(); // замените на Redis/БД с TTL ≥ 72 ч
// Важно: сырое тело, а не express.json()
app.post('/sapport', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
const header = req.get('Sapport-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=')).filter((kv) => kv.length === 2),
);
const t = Number(parts.t);
const sigs = header.split(',').map((p) => p.trim()).filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t || sigs.length === 0) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return res.sendStatus(400);
const body = req.body; // Buffer
const ok = SECRETS.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(body).digest();
return sigs.some((hex) => {
const got = Buffer.from(hex, 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
});
if (!ok) return res.sendStatus(400);
const eventId = req.get('Sapport-Event-Id');
if (seen.has(eventId)) return res.sendStatus(200); // дубль: подтверждаем, не обрабатываем
seen.set(eventId, Date.now());
const event = JSON.parse(body.toString('utf8'));
// быстро поставьте в свою очередь и ответьте
queueMicrotask(() => handle(event));
res.sendStatus(200);
});
function handle(event) { /* ваша логика, идемпотентная по event.id */ }
app.listen(3000);
#Python (Flask)
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRETS = [os.environ["SAPPORT_WEBHOOK_SECRET"]] # при ротации — оба секрета
TOLERANCE_SEC = 300
seen = {} # замените на Redis/БД с TTL ≥ 72 ч
@app.post("/sapport")
def sapport_webhook():
header = request.headers.get("Sapport-Signature", "")
items = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in items if k == "t"), None)
sigs = [v for k, v in items if k == "v1"]
if not t or not sigs:
abort(400)
if abs(time.time() - int(t)) > TOLERANCE_SEC:
abort(400)
body = request.get_data() # сырые байты
signed = t.encode() + b"." + body
ok = any(
hmac.compare_digest(hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest(), sig)
for secret in SECRETS
for sig in sigs
)
if not ok:
abort(400)
event_id = request.headers.get("Sapport-Event-Id")
if event_id in seen:
return "", 200
seen[event_id] = time.time()
event = request.get_json(force=True)
# поставьте в очередь и ответьте быстро
return "", 200
#PHP
<?php
$secrets = [getenv('SAPPORT_WEBHOOK_SECRET')]; // при ротации — оба секрета
$tolerance = 300;
$body = file_get_contents('php://input'); // сырое тело
$header = $_SERVER['HTTP_SAPPORT_SIGNATURE'] ?? '';
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) continue;
if ($kv[0] === 't') $t = (int) $kv[1];
if ($kv[0] === 'v1') $sigs[] = $kv[1];
}
if (!$t || !$sigs || abs(time() - $t) > $tolerance) {
http_response_code(400);
exit;
}
$ok = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) { $ok = true; }
}
}
if (!$ok) {
http_response_code(400);
exit;
}
$eventId = $_SERVER['HTTP_SAPPORT_EVENT_ID'] ?? '';
// проверьте $eventId в своём хранилище (уникальный индекс), при дубле — http_response_code(200); exit;
$event = json_decode($body, true);
// поставьте в очередь и ответьте быстро
http_response_code(200);
#Защита от повтора
Подпись содержит время, а событие — уникальный идентификатор. Используйте оба:
| Мера | Что даёт |
|---|---|
Допуск по времени t (рекомендуется 5 минут) | Перехваченный старый запрос не пройдёт |
Хранение Sapport-Event-Id с TTL не менее 72 часов | Дубль доставки или злонамеренный повтор в пределах окна игнорируется. Повторы платформы растянуты почти на двое суток, поэтому TTL должен их перекрывать |
| Строгое сравнение за постоянное время | Нет утечки по времени сравнения |
| Только HTTPS и закрытый эндпоинт | Подпись защищает целостность, но не секретность тела |
При повторных доставках одного события
Sapport-Event-Idне меняется, аtиv1вычисляются заново. Допуск по времени относится к текущей попытке.
#Доставка, повторы, порядок
| Свойство | Значение |
|---|---|
| Тайм-аут | 10 секунд на ответ |
| Успех | Любой ответ 2xx, полученный в пределах 10 секунд |
| Неудача | Любой другой ответ, тайм-аут, ошибка соединения, ответ 3xx |
| Повторы | Первая попытка и до 8 повторов. Паузы перед повторами: 1 мин, 5 мин, 15 мин, 1 ч, 3 ч, 6 ч, 12 ч, 24 ч |
| После восьмого повтора | Доставка становится «мёртвым письмом»: остаётся в журнале, её можно переотправить |
| Остановленный обмен | Если кошелёк арендатора в минусе или заблокирован, доставки не отправляются, а откладываются на 15 минут без расхода повторов; событие, не доживающее до возобновления (срок хранения 30 суток), закрывается как «мёртвое письмо» с причиной wallet_suspended (Кошелёк) |
| Порядок | Не гарантируется. События могут приходить не в порядке возникновения |
| Дубли | Возможны (доставка «как минимум один раз») |
#Идемпотентная обработка
Из-за повторов и перемешивания порядка обработчик обязан быть идемпотентным:
- Дедупликация по
Sapport-Event-Id(илиidв теле). - Не полагайтесь на порядок. Для событий лидов сравнивайте
version: применяйте изменение, только если версия больше уже сохранённой; иначе игнорируйте как устаревшее. - Читайте актуальное состояние. Если событие «тонкое», запрос
GETпокажет текущее состояние ресурса независимо от порядка прихода событий. - Быстро отвечайте. Подтверждайте
2xxсразу после постановки в очередь; тяжёлую работу делайте асинхронно. Долгая обработка в обработчике приводит к тайм-аутам и лишним повторам. - Временные сбои возвращайте
5xx, постоянные (например, неизвестный тип) —2xx, чтобы не получать бесконечные повторы.
#Журнал доставок
GET /webhooks/{id}/deliveries (область webhooks:manage, курсорный список — Соглашения). Хранится 30 суток.
| Поле | Тип | Описание |
|---|---|---|
id | строка | Идентификатор доставки |
event_id | строка | Идентификатор события |
state | строка | pending, delivered, failed, dead |
next_attempt_at | строка или null | Время следующей попытки |
created_at | строка | Время постановки доставки в очередь |
attempt | число | Номер попытки (1 — первая, далее повторы) |
status_code | число или null | HTTP-код ответа подписчика; null, если ответа не было |
duration_ms | число | Длительность попытки в миллисекундах |
error | строка или null | Короткий код причины сбоя: http_<код>, network_error, redirect_not_followed, https_only, private_address и т. п. Без адресов и текста ответа |
delivered_at | строка или null | Время успешной доставки; null, если попытка неудачна |
Используйте журнал для отладки и для восстановления пропущенных событий: недоставленное событие можно получить из API по идентификатору ресурса. «Мёртвые письма» видны в журнале по state: dead. Состояния доставки: pending, delivered, failed (ждёт повтора), dead. Фильтр: ?state=. Список отсортирован от новых к старым.
#Тестовая доставка
POST /webhooks/{id}/test (нужен Idempotency-Key, подписка должна быть active, иначе 409 invalid_state) ставит на адрес подписки событие webhook.test с корректной подписью и отвечает 202 {"event_id", "type": "webhook.test", "status": "queued"}. Доставка уходит в ближайшую минуту; результат смотрите в журнале. Тестовое событие получает только эта подписка. Тестовое событие проходит тот же путь доставки, что и боевое: удобно проверить сетевую доступность, код проверки подписи и ответ. Отличие от боевого события — livemode: false в теле.
#Ротация секрета
Секрет можно заменить без простоя — в кабинете или запросом POST /webhooks/{id}/rotate-secret (область webhooks:manage). Запрос требует Idempotency-Key. Новый секрет показывается один раз в ответе (поле secret; при повторе запроса — null).
- Запросите ротацию секрета подписки.
- В течение 24 часов действуют два секрета — новый и прежний, и каждая доставка подписана обоими:
Sapport-Signature: t=…,v1=<новый>,v1=<старый>. Клиент разбирает все значенияv1и принимает доставку, если совпало любое. - Разверните новый секрет у себя. Пока вы не закончили, проверяйте подпись обоими секретами (в примерах выше
SECRETS). - После окончания окна прежний секрет перестаёт действовать, в заголовке остаётся одно
v1.
Если секрет утёк: ротируйте немедленно, сверьте журнал доставок и журнал принятых вами событий за период утечки.
#Ошибки
| Код | HTTP | Когда |
|---|---|---|
scope_missing | 403 | У ключа нет webhooks:manage |
validation_failed | 422 | Адрес не https:443, указывает на IP, неизвестное событие; include_pii: true для событий, не входящих в lead.*, contact.*, conversation.* |
scope_missing | 403 | include_pii: true без leads:read/conversations:read у интеграции |
pii_transfer_not_allowed | 403 | Ячейка RU: include_pii: true при стране размещения принимающей системы вне РФ |
not_found | 404 | Подписка не найдена, принадлежит другому арендатору или другой интеграции |
invalid_state | 409 | Больше 10 подписок на интеграцию; тестовое событие для неактивной подписки |
feature_not_in_plan | 403 | Тариф не включает вебхуки |
См. каталог.
#Заметки
- Подписчик получает события только своего арендатора.
- Событие
message.createdв версии v2 «тонкое» всегда: текст сообщения в него не входит. Вебхуки версии v1 отправляли текст сообщения клиента — v2 так не делает. - Рекомендуется один адрес на одну систему и отдельные подписки для боевой и тестовой сред.
- Событие
api_key.expiringприходит за 30 и за 7 суток до срока ключа; удобно направить его в канал уведомлений вашей команды, чтобы не пропустить ротацию. - Подписка на события, содержащие только идентификаторы, требует лишь
webhooks:manage; чтобы по идентификатору прочитать ресурс, нужны его области.