#Вебхуки (версия 2)
Статус: Доступно. Исходящие вебхуки прежней версии (v1) в публичный контракт не входят и заменены версией 2. Подпись версии 2 — не общий секрет, а электронная подпись платформы (JWS, Ed25519): секретов подписки нет.
Назначение: получать события платформы на ваш HTTPS-адрес вместо опроса API: новое сообщение, лид, готовая статья, отчёт стратега.
Доступ: область webhooks:manage для управления подписками. Платно: нет. ПДн: по умолчанию нет (события «тонкие»); флаг подписки include_pii включает ПДн в событиях лидов, контактов и диалогов и требует у интеграции областей чтения этих ресурсов. Содержимое событий, запрошенное по ключу, требует областей соответствующих ресурсов.
Примеры JSON иллюстративны: имена необязательных полей уточнятся в OpenAPI.
#Как это работает
- Вы создаёте подписку: адрес
https://…и список событий. - Платформа записывает событие в надёжную очередь одновременно с изменением данных (событие не теряется, даже если доставка не удалась).
- Воркер доставляет событие
POST-запросом на ваш адрес с подписью платформы. - Вы проверяете подпись открытым ключом платформы (его отдаёт набор ключей JWKS), быстро отвечаете
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 |
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_pii | false |
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 при создании и при каждом соединении:
- схема
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 |
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"
}
]
}
Алгоритм проверки:
- Возьмите сырое тело запроса (не пересериализованный JSON) и три заголовка.
- Проверьте свежесть:
|сейчас − Webhook-Timestamp|не больше 300 секунд (5 минут). Иначе отклоните доставку как повтор. - Из
Webhook-Signatureвозьмите защищённый заголовок и подпись; поkidвыберите ключ из набора JWKS. Если такогоkidв вашем кэше нет, один раз перечитайте набор. - Соберите подписываемую строку
<Webhook-Event-Id>.<Webhook-Timestamp>.<тело>, закодируйте её в BASE64URL и вставьте между двумя точками подписи. Получится обычный компактный JWS. - Проверьте его библиотекой JOSE открытым ключом, разрешив только
EdDSA. - Проверьте, что
Webhook-Event-Idещё не обработан (защита от дублей). - Только после этого разбирайте 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и подпись вычисляются заново. Допуск по времени относится к текущей попытке.
#Ротация ключей платформы
Ключ подписи платформы может меняться. Вам ничего не нужно настраивать, если вы:
- выбираете ключ по
kidиз заголовкаWebhook-Signature, а не берёте «первый ключ набора»; - кэшируете набор не дольше 5 минут (так отдаёт сервер) и перечитываете его при незнакомом
kid.
На время перехода в наборе остаются и прежний, и новый ключи. Если ключ подписи платформы временно недоступен, доставки не отправляются без подписи: они откладываются на 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) |
| Порядок | Не гарантируется. События могут приходить не в порядке возникновения |
| Дубли | Возможны (доставка «как минимум один раз») |
#Идемпотентная обработка
Из-за повторов и перемешивания порядка обработчик обязан быть идемпотентным:
- Дедупликация по
Webhook-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 в теле.
#Ошибки
| Код | 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 | Тариф не включает вебхуки |
service_unavailable | 503 | GET /.well-known/jwks.json: ключ подписи ячейки временно недоступен (доставки в это время откладываются) |
См. каталог.
#Заметки
- Подписчик получает события только своего арендатора.
- Событие
message.createdв версии v2 «тонкое» всегда: текст сообщения в него не входит. Вебхуки версии v1 отправляли текст сообщения клиента — v2 так не делает. - Рекомендуется один адрес на одну систему и отдельные подписки для боевой и тестовой сред.
- Событие
api_key.expiringприходит за 30 и за 7 суток до срока ключа; удобно направить его в канал уведомлений вашей команды, чтобы не пропустить ротацию. - Подписка на события, содержащие только идентификаторы, требует лишь
webhooks:manage; чтобы по идентификатору прочитать ресурс, нужны его области.