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

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

Статус: Доступно. Исходящие вебхуки прежней версии (v1) в публичный контракт не входят и заменены версией 2. Подпись версии 2 — не общий секрет, а электронная подпись платформы (JWS, Ed25519): секретов подписки нет.

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

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

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

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

  1. Вы создаёте подписку: адрес https://… и список событий.
  2. Платформа записывает событие в надёжную очередь одновременно с изменением данных (событие не теряется, даже если доставка не удалась).
  3. Воркер доставляет событие POST-запросом на ваш адрес с подписью платформы.
  4. Вы проверяете подпись открытым ключом платформы (его отдаёт набор ключей JWKS), быстро отвечаете 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
GET /webhooks/{id}/deliveriesЖурнал доставокwebhooks:manage
GET /.well-known/jwks.jsonОткрытые ключи, которыми проверяется подпись доставок. Без авторизациине нужна

#Создание подписки

POST /webhooks, заголовки: Authorization, Content-Type: application/json, Idempotency-Key.

ПараметрТипОбязательноОграниченияПример
urlстрокадаhttps, порт 443, без IP-адреса вместо имени, без переадресацийhttps://hooks.example.com/webhooks
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 "$API_BASE_URL/webhooks" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6b1f0b8e-0f55-4a39-9b0b-1c1d6a1d9a01" \
  -d '{"url": "https://hooks.example.com/webhooks", "events": ["lead.created", "article.ready"]}'

Ответ 201 (иллюстративный):

{
  "id": "whk_EXAMPLE01",
  "url": "https://hooks.example.com/webhooks",
  "events": ["lead.created", "article.ready"],
  "include_pii": false,
  "description": null,
  "status": "active",
  "created_at": "2026-10-11T08:15:30Z",
  "updated_at": "2026-10-11T08:15:30Z"
}

Секрета у подписки нет: сохранять нечего. Каждую доставку подписывает ключ платформы вашей ячейки, а проверяете вы подпись открытым ключом из набора JWKS. При повторе запроса с тем же Idempotency-Key ответ тот же. На интеграцию приходится не больше 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
Webhook-Event-IdУникальный идентификатор события (одинаков во всех повторах одной доставки)
Webhook-Event-TypeТип события, например lead.created
Webhook-TimestampВремя подписи в секундах Unix (UTC). Выставляется заново на каждой попытке
Webhook-SignatureПодпись JWS (EdDSA, Ed25519) с отделённой нагрузкой: <заголовок>..<подпись> (формат)

#Тело: «тонкая» нагрузка по умолчанию

По умолчанию событие содержит только идентификаторы и тип: никаких ПДн. Недостающее вы запрашиваете через 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строкаИдентификатор события (равен Webhook-Event-Id)
typeстрокаТип события (равен Webhook-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: содержимое (контакт, поля лида) приходит прямо в событии. В ячейках EN и ID при настроенном формате JWE поля ПДн в таком событии приходят зашифрованными ключом получателя (страница «Формат JWE для полей ПДн» в меню ячеек EN и ID).

УсловиеЗначение
СобытияТолько lead.*, contact.*, conversation.*. Для остальных типов флаг не применяется; message.created текст сообщения не содержит никогда
ОбластьУ интеграции должны быть leads:read и/или conversations:read — по типам выбранных событий
ЖурналыВключение отражается в журнале раскрытий; каждая такая доставка попадает в журнал выдач ПДн (Ячейки и данные)

Включайте include_pii только для адресов, которым вы вправе передавать ПДн своих клиентов. В ячейке RU для этого требуются обязательный список IP интеграции, указание страны размещения принимающей системы и основания передачи; ПДн передаются только получателям в РФ, при иной стране include_pii для интеграции не выдаётся (403 pii_transfer_not_allowed, Ячейки и данные).

#Подпись (JWS, Ed25519)

Каждая доставка подписана электронной подписью платформы: JWS (RFC 7515), алгоритм EdDSA (Ed25519), отделённая нагрузка (приложение F к RFC 7515). Подпись проверяется открытым ключом, который платформа публикует сама. Закрытый ключ остаётся у платформы, а вы не храните никакого секрета: нечего утекать из вашей системы и нечего передавать.

Три заголовка доставки:

Webhook-Event-Id: evt_EXAMPLE01
Webhook-Timestamp: 1760170530
Webhook-Signature: eyJhbGciOiJFZERTQSIsImtpZCI6IkVYQU1QTEVfS0lEIn0..EXAMPLE_SIGNATURE_NOT_REAL
ЗаголовокСмысл
Webhook-Event-IdИдентификатор события. В повторах одной доставки не меняется
Webhook-TimestampВремя подписи, секунды Unix, UTC. На каждой попытке выставляется заново
Webhook-SignatureКомпактный JWS с пустой средней частью: BASE64URL(защищённый заголовок)..BASE64URL(подпись). В защищённом заголовке: alg = EdDSA и kid — идентификатор ключа

Что подписывается. Строка из трёх частей через точку, в кодировке UTF-8:

<Webhook-Event-Id>.<Webhook-Timestamp>.<тело запроса>

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

#Проверка подписи и набор ключей JWKS

Открытые ключи ячейки отдаёт GET /api/public/v1/.well-known/jwks.json (формат JWKS, RFC 7517). Запрос не требует ключа доступа. Ответ можно кэшировать: заголовок Cache-Control: max-age=300. Каждый ключ набора:

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "EXAMPLE_PUBLIC_KEY_BASE64URL",
      "kid": "EXAMPLE_KID",
      "alg": "EdDSA",
      "use": "sig"
    }
  ]
}

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

  1. Возьмите сырое тело запроса (не пересериализованный JSON) и три заголовка.
  2. Проверьте свежесть: |сейчас − Webhook-Timestamp| не больше 300 секунд (5 минут). Иначе отклоните доставку как повтор.
  3. Из Webhook-Signature возьмите защищённый заголовок и подпись; по kid выберите ключ из набора JWKS. Если такого kid в вашем кэше нет, один раз перечитайте набор.
  4. Соберите подписываемую строку <Webhook-Event-Id>.<Webhook-Timestamp>.<тело>, закодируйте её в BASE64URL и вставьте между двумя точками подписи. Получится обычный компактный JWS.
  5. Проверьте его библиотекой JOSE открытым ключом, разрешив только EdDSA.
  6. Проверьте, что Webhook-Event-Id ещё не обработан (защита от дублей).
  7. Только после этого разбирайте JSON.

Если подпись не совпала, ответьте 4xx (например, 400) и не обрабатывайте тело.

Примеры ниже проверяют только подпись, и только библиотекой: собственных реализаций алгоритма не пишите. Хранилище идентификаторов событий (seen) замените на Redis или базу с TTL не менее 72 часов.

#Node.js (jose, Express)

import express from 'express';
import { base64url, compactVerify, createRemoteJWKSet } from 'jose';

const app = express();
// Набор ключей кэшируется библиотекой; при неизвестном kid он перечитывается сам.
const JWKS = createRemoteJWKSet(new URL(`${process.env.API_BASE_URL}/.well-known/jwks.json`));
const TOLERANCE_SEC = 300;
const seen = new Map(); // замените на Redis/БД с TTL не менее 72 ч

// Важно: сырое тело, а не express.json()
app.post('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  const eventId = req.get('Webhook-Event-Id') ?? '';
  const timestamp = req.get('Webhook-Timestamp') ?? '';
  const signature = req.get('Webhook-Signature') ?? '';
  const body = req.body; // Buffer

  if (!/^\d{1,12}$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SEC) {
    return res.sendStatus(400);
  }
  const m = /^([\w-]+)\.\.([\w-]+)$/.exec(signature);
  if (!eventId || !m) return res.sendStatus(400);

  // Нагрузка отделена от подписи: собираем её сами и вставляем между точками.
  const payload = Buffer.concat([Buffer.from(`${eventId}.${timestamp}.`), body]);
  try {
    await compactVerify(`${m[1]}.${base64url.encode(payload)}.${m[2]}`, JWKS, { algorithms: ['EdDSA'] });
  } catch {
    return res.sendStatus(400);
  }

  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 (PyJWT, Flask)

В PyJWT проверку выполняет PyJWS, а не jwt.decode(): нагрузка подписи не JSON, а строка из трёх частей. Нужен пакет cryptography.

import base64
import json
import os
import time

import jwt
import requests
from flask import Flask, abort, request
from jwt import PyJWK, PyJWS

app = Flask(__name__)
JWKS_URL = os.environ["API_BASE_URL"] + "/.well-known/jwks.json"
TOLERANCE_SEC = 300
seen = {}        # замените на Redis/БД с TTL не менее 72 ч
_jwks = {"keys": [], "at": 0.0}


def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def key_for(kid: str):
    """Ключ из набора JWKS; набор кэшируется на 5 минут и перечитывается при неизвестном kid."""
    for attempt in (0, 1):
        if attempt == 1 or time.time() - _jwks["at"] > 300:
            _jwks.update(keys=requests.get(JWKS_URL, timeout=5).json()["keys"], at=time.time())
        for jwk in _jwks["keys"]:
            if jwk.get("kid") == kid:
                return PyJWK.from_dict(jwk).key
    return None


@app.post("/webhooks")
def handle_webhook():
    event_id = request.headers.get("Webhook-Event-Id", "")
    timestamp = request.headers.get("Webhook-Timestamp", "")
    signature = request.headers.get("Webhook-Signature", "")
    body = request.get_data()  # сырые байты

    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SEC:
        abort(400)
    parts = signature.split(".")
    if not event_id or len(parts) != 3 or parts[1] != "":
        abort(400)

    try:
        header = json.loads(base64.urlsafe_b64decode(parts[0] + "=" * (-len(parts[0]) % 4)))
        key = key_for(header.get("kid", ""))
        if key is None:
            abort(400)
        payload = f"{event_id}.{timestamp}.".encode() + body
        # Нагрузка отделена от подписи: собираем её сами и вставляем между точками.
        PyJWS().decode(f"{parts[0]}.{b64url(payload)}.{parts[2]}", key, algorithms=["EdDSA"])
    except (jwt.PyJWTError, ValueError):
        abort(400)

    if event_id in seen:
        return "", 200
    seen[event_id] = time.time()

    event = json.loads(body)
    # поставьте событие в очередь и ответьте быстро
    return "", 200

#PHP (libsodium)

Библиотека firebase/php-jwt ожидает нагрузку в виде JSON, а здесь нагрузка не JSON, поэтому для PHP подпись проверяется стандартным расширением sodium (оно входит в PHP начиная с 7.2).

<?php
const TOLERANCE = 300;

function b64urlDecode(string $s): string|false {
    return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4), true);
}
function b64urlEncode(string $s): string {
    return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}

// Набор ключей: кэшируйте его на 5 минут (APCu, файл) и перечитывайте при неизвестном kid.
function publicKey(string $kid, bool $refresh = false): ?string {
    static $keys = null;
    if ($keys === null || $refresh) {
        $set = json_decode(file_get_contents(getenv('API_BASE_URL') . '/.well-known/jwks.json'), true);
        $keys = [];
        foreach ($set['keys'] ?? [] as $jwk) {
            if (($jwk['kty'] ?? '') === 'OKP' && ($jwk['crv'] ?? '') === 'Ed25519') {
                $keys[$jwk['kid']] = b64urlDecode($jwk['x']);
            }
        }
    }
    return $keys[$kid] ?? ($refresh ? null : publicKey($kid, true));
}

$body      = file_get_contents('php://input');                  // сырое тело
$eventId   = $_SERVER['HTTP_WEBHOOK_EVENT_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';

$parts = explode('.', $signature);
if ($eventId === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE
    || count($parts) !== 3 || $parts[1] !== '') {
    http_response_code(400); exit;
}

$header = json_decode((string) b64urlDecode($parts[0]), true);
$pub    = is_array($header) && ($header['alg'] ?? '') === 'EdDSA' ? publicKey((string) ($header['kid'] ?? '')) : null;
$sig    = b64urlDecode($parts[2]);

// Нагрузка отделена от подписи: подписывается "<защищённый заголовок>.<BASE64URL(нагрузки)>".
$input = $parts[0] . '.' . b64urlEncode($eventId . '.' . $timestamp . '.' . $body);
if ($pub === null || $sig === false || !sodium_crypto_sign_verify_detached($sig, $input, $pub)) {
    http_response_code(400); exit;
}

// Проверьте $eventId в своём хранилище (уникальный индекс); при дубле: http_response_code(200); exit;
$event = json_decode($body, true);
// поставьте событие в очередь и ответьте быстро
http_response_code(200);

#Защита от повтора

Подпись содержит время, а событие — уникальный идентификатор. Используйте оба:

МераЧто даёт
Допуск по Webhook-Timestamp (300 секунд)Перехваченный старый запрос не пройдёт
Хранение Webhook-Event-Id с TTL не менее 72 часовДубль доставки или злонамеренный повтор в пределах окна игнорируется. Повторы платформы растянуты почти на двое суток, поэтому TTL должен их перекрывать
Проверка библиотекой JOSE, а не сравнение строк вручнуюНет собственного кода сравнения подписей и разбора формата
Только HTTPS и закрытый эндпоинтПодпись защищает целостность и подлинность отправителя, но не секретность тела

При повторных доставках одного события Webhook-Event-Id не меняется, а Webhook-Timestamp и подпись вычисляются заново. Допуск по времени относится к текущей попытке.

#Ротация ключей платформы

Ключ подписи платформы может меняться. Вам ничего не нужно настраивать, если вы:

На время перехода в наборе остаются и прежний, и новый ключи. Если ключ подписи платформы временно недоступен, доставки не отправляются без подписи: они откладываются на 15 минут без расхода повторов (в журнале error: signing_key_unavailable), а GET /.well-known/jwks.json отвечает 503 service_unavailable.

#Доставка, повторы, порядок

СвойствоЗначение
Тайм-аут10 секунд на ответ
УспехЛюбой ответ 2xx, полученный в пределах 10 секунд
НеудачаЛюбой другой ответ, тайм-аут, ошибка соединения, ответ 3xx
ПовторыПервая попытка и до 8 повторов. Паузы перед повторами: 1 мин, 5 мин, 15 мин, 1 ч, 3 ч, 6 ч, 12 ч, 24 ч
После восьмого повтораДоставка становится «мёртвым письмом»: остаётся в журнале, её можно переотправить
Остановленный обменЕсли кошелёк арендатора в минусе или заблокирован, доставки не отправляются, а откладываются на 15 минут без расхода повторов; событие, не доживающее до возобновления (срок хранения 30 суток), закрывается как «мёртвое письмо» с причиной wallet_suspended (Кошелёк)
Ключ подписи недоступенДоставка не уходит без подписи: откладывается на 15 минут без расхода повторов (в журнале signing_key_unavailable)
ПорядокНе гарантируется. События могут приходить не в порядке возникновения
ДублиВозможны (доставка «как минимум один раз»)

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

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

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

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 в теле.

#Ошибки

Код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Тариф не включает вебхуки
service_unavailable503GET /.well-known/jwks.json: ключ подписи ячейки временно недоступен (доставки в это время откладываются)

См. каталог.

#Заметки