◆ Sapport для разработчиков Справочник API Каталог ошибок OpenAPI
Разделы

#Вебхуки (версия 2)

Статус: Доступно. Исходящие вебхуки прежней версии (v1) в публичный контракт не входят и будут заменены.

Назначение: получать события платформы на ваш HTTPS-адрес вместо опроса API: новое сообщение, лид, готовая статья, отчёт стратега.

Доступ: область webhooks:manage для управления подписками. Платно: нет. ПДн: по умолчанию нет (события «тонкие»); флаг подписки include_pii включает ПДн в событиях лидов, контактов и диалогов и требует у интеграции областей чтения этих ресурсов. Содержимое событий, запрошенное по ключу, требует областей соответствующих ресурсов.

Примеры JSON иллюстративны: имена необязательных полей уточнятся в OpenAPI.

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

  1. Вы создаёте подписку: адрес https://… и список событий.
  2. Платформа записывает событие в надёжную очередь одновременно с изменением данных (событие не теряется, даже если доставка не удалась).
  3. Воркер доставляет событие POST-запросом на ваш адрес с подписью.
  4. Вы проверяете подпись, быстро отвечаете 2xx и обрабатываете событие у себя.
  5. При неудаче платформа повторяет доставку: до 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.testwebhooks: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_piifalse
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 при создании и при каждом соединении:

Нарушение при создании — 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-Typeapplication/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)
v1HMAC-SHA256 в шестнадцатеричной записи над строкой t + "." + тело с вашим секретом подписи

Что подписывается. Тело запроса целиком в том виде, в каком оно пришло (байты, до разбора JSON). Тело содержит идентификатор, тип и версию схемы события, поэтому подпись защищает и от подмены содержимого, и от подмены типа события.

#Алгоритм проверки

  1. Получите сырое тело запроса (не пересериализованный JSON).
  2. Разберите Sapport-Signature: значения t и все v1.
  3. Вычислите HMAC_SHA256(секрет, t + "." + тело) в hex.
  4. Сравните с каждым v1 за постоянное время. Достаточно совпадения с любым.
  5. Проверьте свежесть: |сейчас − t| не больше допуска (рекомендуется 5 минут). Иначе отклоните как повтор.
  6. Проверьте, что Sapport-Event-Id ещё не обработан (защита от повтора и дублей).
  7. Только после этого разбирайте 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 (Кошелёк)
ПорядокНе гарантируется. События могут приходить не в порядке возникновения
ДублиВозможны (доставка «как минимум один раз»)

#Идемпотентная обработка

Из-за повторов и перемешивания порядка обработчик обязан быть идемпотентным:

#Журнал доставок

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число или nullHTTP-код ответа подписчика; 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).

  1. Запросите ротацию секрета подписки.
  2. В течение 24 часов действуют два секрета — новый и прежний, и каждая доставка подписана обоими: Sapport-Signature: t=…,v1=<новый>,v1=<старый>. Клиент разбирает все значения v1 и принимает доставку, если совпало любое.
  3. Разверните новый секрет у себя. Пока вы не закончили, проверяйте подпись обоими секретами (в примерах выше SECRETS).
  4. После окончания окна прежний секрет перестаёт действовать, в заголовке остаётся одно v1.

Если секрет утёк: ротируйте немедленно, сверьте журнал доставок и журнал принятых вами событий за период утечки.

#Ошибки

КодHTTPКогда
scope_missing403У ключа нет webhooks:manage
validation_failed422Адрес не https:443, указывает на IP, неизвестное событие; include_pii: true для событий, не входящих в lead.*, contact.*, conversation.*
scope_missing403include_pii: true без leads:read/conversations:read у интеграции
pii_transfer_not_allowed403Ячейка RU: include_pii: true при стране размещения принимающей системы вне РФ
not_found404Подписка не найдена, принадлежит другому арендатору или другой интеграции
invalid_state409Больше 10 подписок на интеграцию; тестовое событие для неактивной подписки
feature_not_in_plan403Тариф не включает вебхуки

См. каталог.

#Заметки