Webhook: доставка лидов в вашу CRM
Когда AI-агент квалифицировал лид и передаёт его менеджеру, платформа отправляет один HTTP POST с JSON-данными лида на ваш эндпоинт. Вы раскладываете поля в свою CRM на своей стороне.
Требования к эндпоинту
- Только
https://. - Отвечать
2xxв пределах ~10 секунд. - Идемпотентность: дедупликация по
lead.id(см. «Доставка»).
Аутентификация
Каждый запрос платформа подписывает секретом в заголовке Authorization: Bearer <секрет>. Секрет (UUID) генерируется автоматически при подключении назначения в личном кабинете — скопируйте его оттуда и сохраните на своей стороне. Ваш эндпоинт сравнивает значение со своим (константным по времени сравнением) и отклоняет запрос при несовпадении. Всегда используйте https://.
PHP
$expected = getenv('SWS_WEBHOOK_SECRET');
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = preg_replace('/^Bearer\s+/i', '', $header);
if (!hash_equals($expected, $token)) {
http_response_code(401);
exit;
}Node.js
const crypto = require("crypto");
function verify(req) {
const expected = process.env.SWS_WEBHOOK_SECRET;
const token = (req.headers.authorization || "").replace(/^Bearer\s+/i, "");
const a = Buffer.from(token);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hmac # только compare_digest — сравнение строк за
import os # константное время; HMAC-подписи здесь нет
def verify(request) -> bool:
expected = os.environ["SWS_WEBHOOK_SECRET"]
header = request.headers.get("Authorization", "")
token = header.removeprefix("Bearer ").strip()
return hmac.compare_digest(expected, token)Во всех трёх примерах происходит одно и то же: ваш сохранённый секрет сравнивается с токеном из заголовка как обычная строка. hash_equals / timingSafeEqual / hmac.compare_digest — это «безопасный ==» (сравнение за константное время, защита от timing-атак), а не криптографическая подпись.
Заголовки
| Заголовок | Значение |
|---|---|
| Content-Type | application/json |
| Authorization | Bearer <секрет> — всегда |
| X-SWS-Event | тип события, напр. lead.qualified |
| X-SWS-Event-Id | UUID доставки — одинаков на всех повторах (ключ дедупликации) |
| X-SWS-Delivery | номер попытки: 1 для первой, инкрементируется на ретраях |
Тело запроса
Один JSON-объект. Скалярные поля всегда присутствуют, null — если значение неизвестно. custom_attributes — массив ([], если атрибутов нет). Голосовой лид приходит тем же payload, что и лид из чата.
Пример
{
"event": "lead.qualified",
"event_id": "5f3b2c9a-1d4e-4a7b-9c2f-8e6d0a1b2c3d",
"api_version": "1",
"sent_at": "2026-07-16T09:12:34.567Z",
"organization_id": 2,
"lead": {
"id": 461,
"url": "https://salesworkshop.ai/leads/461",
"channel": "telegram",
"type": "new",
"status": "active",
"is_hot": true,
"summary": "Интересует годовой абонемент в клуб на Ленина, спрашивал про бассейн и рассрочку. Готов на пробную тренировку в выходные.",
"next_step": "Записать на пробную тренировку в субботу",
"created_at": "2026-07-16T08:40:00.000Z",
"qualification": {
"current_situation": "Ходит в зал у дома, не устраивает оборудование",
"volume": "Годовой абонемент, 1 человек",
"goals": "Похудеть к лету, нужен бассейн",
"timeline": "Готов купить на этой неделе",
"budget_level": "до 40 000 ₽"
},
"custom_attributes": [
{
"key": "club",
"label": "Клуб",
"type": "ENUM",
"is_multiple": false,
"value": "Ленина"
},
{
"key": "membership",
"label": "Абонемент",
"type": "STRING",
"is_multiple": false,
"value": "Годовой"
},
{
"key": "promised_budget",
"label": "Бюджет",
"type": "MONEY",
"is_multiple": false,
"value": 40000
},
{
"key": "trial_date",
"label": "Дата пробной",
"type": "DATE",
"is_multiple": false,
"value": "2026-07-18"
},
{
"key": "interests",
"label": "Интересы",
"type": "STRING",
"is_multiple": true,
"value": [
"бассейн",
"сауна",
"групповые"
]
}
]
},
"contact": {
"first_name": "Иван",
"last_name": "Петров",
"phone": "+79991234567",
"email": null,
"telegram": "@ivan_petrov",
"whatsapp": null,
"company": null,
"role": "unknown",
"channel": "telegram"
}
}Справочник полей
| Поле | Тип | Описание |
|---|---|---|
| event | string | Тип события. Сейчас lead.qualified (плюс webhook.test при проверке подключения). |
| event_id | string (UUID) | Идентификатор доставки. Стабилен на ретраях — ключ дедупликации. |
| api_version | string | Версия контракта. Сейчас "1". |
| sent_at | string (ISO-8601, UTC) | Момент отправки. |
| organization_id | number | ID организации в платформе. |
| lead.id | number | ID лида в платформе — ключ upsert на вашей стороне. |
| lead.url | string | Ссылка на карточку лида в платформе. |
| lead.channel | string|null | Канал: telegram, whatsapp_baileys, voice, … |
| lead.type | string | new, re_engaged, upsell, renewal. |
| lead.status | string | new, active, won, lost, no_response. |
| lead.is_hot | boolean | Горячий лид. |
| lead.summary | string|null | Краткое резюме диалога. |
| lead.next_step | string|null | Рекомендованный следующий шаг (best-effort). |
| lead.created_at | string|null | Когда создан лид (ISO-8601). |
| lead.qualification.* | string|null | Пять полей BANT: current_situation, volume, goals, timeline, budget_level. |
| lead.custom_attributes | array | Кастомные атрибуты организации. [] — если не настроены. |
| contact.first_name / last_name | string|null | Имя / фамилия. |
| contact.phone | string|null | Телефон. |
| contact.email | string|null | Email. |
| contact.telegram | string|null | Telegram-username с @. |
| contact.whatsapp | string|null | WhatsApp-идентификатор (для канала WhatsApp). |
| contact.company | string|null | Компания. |
| contact.role | string|null | Роль: decision_maker, influencer, executor, unknown. |
| contact.channel | string | Канал контакта. |
Тестовое событие webhook.test
При «Проверить подключение» платформа отправляет POST с теми же заголовками (включая Authorization: Bearer), но без поля lead. Ваш приёмник обязан ветвиться по полю event: обрабатывать лида только при event == "lead.qualified", а на любые другие события (включая webhook.test и будущие типы) отвечать 2xx без создания сделки — иначе тест подключения упадёт или создаст пустую сделку.
{
"event": "webhook.test",
"event_id": "d3c1a2b4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"api_version": "1",
"sent_at": "2026-07-19T10:00:00.000Z",
"message": "Тестовое событие от Sales Workshop AI. Подключение webhook работает."
}Доставка и идемпотентность
- Best-effort, at-least-once. Запрос отправляется синхронно на handoff. На сетевую ошибку, таймаут,
429или5xx— до 2 повторов (паузы 1s, 3s). На прочие4xxповтора нет. Таймаут попытки ~10 секунд. - Дедупликация. Все повторы одной доставки несут тот же
event_id— обрабатывайте повтор как дубликат. - Upsert по
lead.id. Разные события по одному лиду придут с разнымиevent_id, но одинаковымlead.id. - Ошибки доставки видны в интерфейсе платформы и не влияют на работу AI-агента.
Кастомные атрибуты
lead.custom_attributes — массив { key, label, type, is_multiple, value }.type ∈ STRING, NUMBER, MONEY, DATE, BOOLEAN, ENUM; value — в нативном JSON-типе (MONEY → число рублей, DATE → YYYY-MM-DD, BOOLEAN → boolean, ENUM/STRING → строка). Маппинг key → поле вашей CRM держите на своей стороне.
Множественные атрибуты: при is_multiple: true value приходит массивом элементов того же базового типа, напр. { "key": "interests", "type": "STRING", "is_multiple": true, "value": ["бассейн", "сауна"] }. type остаётся базовым; при is_multiple: false value — скаляр. Ветвите обработку по флагу is_multiple.
Версионирование
api_version фиксирует версию контракта. В рамках версии изменения только аддитивные — пишите приёмник устойчиво к новым необязательным полям.