Webhook: доставка лидов в вашу CRM

Когда AI-агент квалифицировал лид и передаёт его менеджеру, платформа отправляет один HTTP POST с JSON-данными лида на ваш эндпоинт. Вы раскладываете поля в свою CRM на своей стороне.

Требования к эндпоинту

Аутентификация

Каждый запрос платформа подписывает секретом в заголовке 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-Typeapplication/json
AuthorizationBearer <секрет> — всегда
X-SWS-Eventтип события, напр. lead.qualified
X-SWS-Event-IdUUID доставки — одинаков на всех повторах (ключ дедупликации)
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,
  "agent": {
    "id": 21,
    "name": "Фитнес — чат"
  },
  "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",
    "direction": "inbound",
    "click_id": null,
    "click_source": null,
    "start_param": "summer_promo",
    "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",
    "callback_phone": "+79995554433",
    "email": null,
    "telegram": "@ivan_petrov",
    "whatsapp": null,
    "channel_user_id": null,
    "channel_handle_label": null,
    "company": null,
    "role": "unknown",
    "channel": "telegram"
  },
  "updated_fields": [],
  "client_message": null
}

Справочник полей

ПолеТипОписание
eventstringТип события: lead.qualified, lead.updated (плюс webhook.test при проверке подключения).
event_idstring (UUID)Идентификатор доставки. Стабилен на ретраях — ключ дедупликации.
api_versionstringВерсия контракта. Сейчас "1".
sent_atstring (ISO-8601, UTC)Момент отправки.
organization_idnumberID организации в платформе.
agentobject|nullAI-агент, который привёл лид. null, если определить не удалось.
agent.idnumberID агента — стабилен, маршрутизируйте по нему, а не по имени.
agent.namestringНазвание агента, как оно задано в платформе. Может измениться.
lead.idnumberID лида в платформе — ключ upsert на вашей стороне.
lead.urlstringСсылка на карточку лида в платформе.
lead.channelstring|nullКанал: telegram, whatsapp_baileys, voice, …
lead.typestringnew, re_engaged, upsell, renewal.
lead.statusstringnew, active, won, lost, no_response.
lead.is_hotbooleanГорячий лид.
lead.summarystring|nullКраткое резюме диалога.
lead.next_stepstring|nullРекомендованный следующий шаг (best-effort).
lead.created_atstring|nullКогда создан лид (ISO-8601).
lead.directionstring|nullКто начал общение, из которого создан лид: inbound — клиент позвонил или написал сам, outbound — мы позвонили или написали первыми. Ставится один раз и не меняется.
lead.start_paramstring|nullПараметр start ссылки на бота (t.me/<бот>?start=…, в MAX — max.ru/<бот>?start=…), как пришёл: до 64 символов A-Z, a-z, 0-9, _ и -. Сюда попадает любая ваша метка — свой идентификатор, промокод, тег кампании. null, если клиент пришёл не по такой ссылке или не из Telegram/MAX.
lead.click_idstring|nullИдентификатор рекламного клика: значение после префикса yclid_, gclid_, fbclid_, ttclid_, rb_clickid_ или msclkid_ в start_param. null, если такого префикса нет.
lead.click_sourcestring|nullРекламная сеть по префиксу: yandex, google, meta, tiktok, vk, microsoft. null, если префикса нет.
lead.qualification.*string|nullПять полей BANT: current_situation, volume, goals, timeline, budget_level.
lead.custom_attributesarrayКастомные атрибуты организации. [] — если не настроены.
contact.first_name / last_namestring|nullИмя / фамилия.
contact.phonestring|nullТелефон, с которого обратились (АОН входящего звонка или набранный нами номер). Не меняется. null у чат-контактов.
contact.callback_phonestring|nullТелефон для связи — номер, на который клиент попросил перезвонить. null, если не просили.
contact.emailstring|nullEmail.
contact.telegramstring|nullTelegram-username с @.
contact.whatsappstring|nullWhatsApp-идентификатор (для канала WhatsApp).
contact.channel_user_idstring|nullИдентификатор клиента в канале, откуда он написал, — для каналов без публичного имени (MAX, Telegram без @username).
contact.channel_handle_labelstring|nullЧеловекочитаемое название этого идентификатора: MAX, Telegram ID, WhatsApp.
updated_fieldsstring[]Что изменилось. Пусто в lead.qualified, заполнено в lead.updated.
client_messagestring|nullДословный текст клиента, вызвавший lead.updated. null в lead.qualified.
contact.companystring|nullКомпания.
contact.rolestring|nullРоль: decision_maker, influencer, executor, unknown.
contact.channelstringКанал контакта.

Событие lead.updated

Клиент часто называет телефон или мессенджер уже после того, как лид ушёл к вам: бот просит контакт в момент передачи менеджеру, а человек отвечает через несколько секунд. Тогда платформа шлёт второе событие lead.updated с тем же lead.id: блок contact уже содержит новые данные, updated_fields перечисляет изменившиеся поля, а client_message — дословную реплику клиента. Обновляйте существующую сделку по lead.id, а не создавайте новую.

{
  "event": "lead.updated",
  "event_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
  "api_version": "1",
  "sent_at": "2026-09-10T08:37:49.512Z",
  "organization_id": 12,
  "lead": {
    "id": 1880,
    "url": "https://salesworkshop.ai/leads/1880",
    "…": "те же поля, что в lead.qualified"
  },
  "contact": {
    "first_name": "Надежда",
    "phone": null,
    "callback_phone": null,
    "telegram": "@irina_cue",
    "whatsapp": null,
    "channel_user_id": "337709712",
    "channel_handle_label": "MAX",
    "channel": "max",
    "…": "те же поля, что в lead.qualified"
  },
  "updated_fields": [
    "first_name",
    "telegram"
  ],
  "client_message": "@irina_cue"
}

Тестовое событие webhook.test

При «Проверить подключение» платформа отправляет POST с теми же заголовками (включая Authorization: Bearer), но без поля lead. Ваш приёмник обязан ветвиться по полю event: обрабатывать лида только при event == "lead.qualified", обновлять её при event == "lead.updated", а на любые другие события (включая 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 работает."
}

Доставка и идемпотентность

Кастомные атрибуты

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 фиксирует версию контракта. В рамках версии изменения только аддитивные — пишите приёмник устойчиво к новым необязательным полям.