Event webhook: AI agent inquiries, leads and calls in your analytics
Three events, one per business fact: a client got in touch for the first time (messaged in a messenger or called), a lead was handed off to a manager, a call ended. For each one the platform sends an HTTP POST with JSON to your endpoint. Every event includes channel (where the client wrote or called: the type, id and name of the bot or trunk), agent (which AI agent handled it), contact and lead, and an inquiry from an ad also carries lead.click_id. This is a separate integration from the lead webhook destination; you can enable both, and one receiver can serve them — they share the same headers and payload style.
Connecting
- Dashboard → Settings → Integrations → “Event webhook”.
- Enter an
https://URL, copy the generated secret and tick the events you need. - Click “Test connection” — a
webhook.testevent arrives; respond with2xx. - Save. From then on events are sent automatically; recent deliveries and errors are shown in the same place.
Endpoint requirements
https://only.- Respond with
2xxwithin ~10 seconds. Process asynchronously — respond first, compute later. - Be idempotent on
event_id— a delivery retry carries the same identifier. - Branch on
eventand respond2xxto unknown types (includingwebhook.test).
Event catalogue
The subscription is exact: only the ticked types are delivered; new types added to the platform later are not enabled automatically. Expand an event to see the exact JSON that will hit your URL.
▸lead.createdNew inquiryLeads
A client messaged you for the first time in a messenger (Telegram, WhatsApp, MAX) or called — a lead was created. One event per inquiry: exactly what you need for a “messaged in a messenger” conversion in Yandex Direct (lead.click_id from the bot link). data.type: new, re_engaged, upsell.
{
"event": "lead.created",
"event_id": "0b7e6c4a-2f1d-4e8b-9a3c-5d6e7f8a9b0c",
"api_version": "1",
"occurred_at": "2026-09-14T09:40:00Z",
"sent_at": "2026-09-14T10:15:04.001Z",
"organization_id": 2,
"demo_mode": null,
"channel": {
"type": "telegram",
"id": 12,
"name": "Бот продаж"
},
"agent": {
"id": 21,
"name": "Фитнес — чат"
},
"contact": {
"id": 7,
"first_name": "Иван",
"last_name": "Петров",
"phone": null,
"callback_phone": "+79995554433",
"email": null,
"telegram": "@ivan_petrov",
"whatsapp": null,
"channel_user_id": null,
"channel_handle_label": null,
"company": null,
"role": "unknown",
"channel": "telegram"
},
"lead": {
"id": 461,
"url": "https://salesworkshop.ai/leads/461",
"type": "new",
"status": "new",
"mode": "ai",
"is_hot": false,
"goal_reached": false,
"summary": null,
"channel": "telegram",
"call_resolution": null,
"direction": "inbound",
"click_id": "16234567890123456789",
"click_source": "yandex",
"start_param": "yclid_16234567890123456789",
"created_at": "2026-09-14T09:40:00Z",
"qualification": {
"current_situation": null,
"volume": null,
"goals": null,
"timeline": null,
"budget_level": null
},
"custom_attributes": {}
},
"chat": {
"id": 55
},
"actor": null,
"data": {
"message_id": 899,
"content": "Новый лид создан",
"lead_id": 461,
"type": "new"
}
}▸lead.handoffQualified and handed off to a managerLeads
The AI qualified the client and handed the lead off to a manager (data.reason: ready_to_buy, human_request, …); the contact and lead blocks carry the collected data and qualification.
{
"event": "lead.handoff",
"event_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"api_version": "1",
"occurred_at": "2026-09-14T10:21:45Z",
"sent_at": "2026-09-14T10:15:04.001Z",
"organization_id": 2,
"demo_mode": null,
"channel": {
"type": "telegram",
"id": 12,
"name": "Бот продаж"
},
"agent": {
"id": 21,
"name": "Фитнес — чат"
},
"contact": {
"id": 7,
"first_name": "Иван",
"last_name": "Петров",
"phone": null,
"callback_phone": "+79995554433",
"email": null,
"telegram": "@ivan_petrov",
"whatsapp": null,
"channel_user_id": null,
"channel_handle_label": null,
"company": null,
"role": "unknown",
"channel": "telegram"
},
"lead": {
"id": 461,
"url": "https://salesworkshop.ai/leads/461",
"type": "new",
"status": "active",
"mode": "manual",
"is_hot": true,
"goal_reached": false,
"summary": "Интересует годовой абонемент, спрашивал про бассейн и рассрочку.",
"channel": "telegram",
"call_resolution": null,
"direction": "inbound",
"click_id": "16234567890123456789",
"click_source": "yandex",
"start_param": "yclid_16234567890123456789",
"created_at": "2026-09-14T09:40:00Z",
"qualification": {
"current_situation": "Ходит в зал у дома, не устраивает оборудование",
"volume": "Годовой абонемент, 1 человек",
"goals": "Похудеть к лету, нужен бассейн",
"timeline": "Готов купить на этой неделе",
"budget_level": "до 40 000 ₽"
},
"custom_attributes": {
"club": "Ленина"
}
},
"chat": {
"id": 55
},
"actor": {
"type": "agent",
"id": "21",
"name": "Фитнес — чат"
},
"data": {
"message_id": 910,
"content": "AI запросил передачу менеджеру: ready_to_buy",
"reason": "ready_to_buy",
"from_mode": "ai",
"to_mode": "manual"
}
}▸call.endedCall endedCalls
The call result: duration, outcome, termination reason, transfer to a manager (lead.call_resolution), summary.
{
"event": "call.ended",
"event_id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a",
"api_version": "1",
"occurred_at": "2026-09-14T11:04:51Z",
"sent_at": "2026-09-14T10:15:04.001Z",
"organization_id": 2,
"demo_mode": null,
"channel": {
"type": "voice",
"id": 3,
"name": "Sipuni — офис"
},
"agent": {
"id": 32,
"name": "Фитнес — звонки"
},
"contact": {
"id": 7,
"first_name": "Иван",
"last_name": "Петров",
"phone": "+79991234567",
"callback_phone": null,
"email": null,
"telegram": null,
"whatsapp": null,
"channel_user_id": null,
"channel_handle_label": null,
"company": null,
"role": "unknown",
"channel": "voice"
},
"lead": {
"id": 462,
"url": "https://salesworkshop.ai/leads/462",
"type": "new",
"status": "active",
"mode": "ai",
"is_hot": true,
"goal_reached": false,
"summary": "Позвонил узнать про абонемент, договорились о пробной тренировке в субботу.",
"channel": "phone",
"call_resolution": null,
"direction": "inbound",
"click_id": null,
"click_source": null,
"start_param": null,
"created_at": "2026-09-14T09:40:00Z",
"qualification": {
"current_situation": "Ходит в зал у дома, не устраивает оборудование",
"volume": "Годовой абонемент, 1 человек",
"goals": "Похудеть к лету, нужен бассейн",
"timeline": "Пробная тренировка в субботу",
"budget_level": "до 40 000 ₽"
},
"custom_attributes": {
"club": "Ленина"
}
},
"chat": {
"id": 56
},
"actor": {
"type": "system",
"id": null,
"name": "Голосовой вызов"
},
"data": {
"message_id": 1201,
"content": "Голосовой вызов: Заинтересован (2:41)",
"voice_call_id": "3f2a9c1e-7b4d-4e6a-9c8b-1d2e3f4a5b6c",
"duration_seconds": 161,
"outcome": "interested",
"outcome_label": "Заинтересован",
"transcript_count": 18,
"direction": "inbound",
"termination_reason": "USER_HANGUP",
"termination_detail": null
}
}Agent replies, individual messages and call utterances, status changes and internal timeline events are not published — the catalogue is extended on request. The call recording link is never sent out.
Ad conversions: “messaged in a messenger” with a click ID
The lead.created event arrives once per inquiry — at the client's first message in a messenger (or first call), before the agent replies. That is exactly the “messaged in a messenger” conversion: no need to deduplicate messages within one conversation. To link it to an ad click, carry the click ID through to the bot.
- Point your ads at the bot with a link that carries the click ID in the
startparameter:https://t.me/<bot>?start=yclid_<yclid>(in MAX —https://max.ru/<bot>?start=yclid_<yclid>) for Yandex Direct,gclid_…for Google Ads,fbclid_…for Meta,ttclid_…for TikTok,rb_clickid_…for VK. Ad networks only append the click ID to your website's URL, so the usual setup is a redirect: the ad leads to a page on your site, which reads the ID from its URL and redirects to the bot link with it (the parameter allows only letters, digits,_and-, up to 64 characters). - In Telegram the client's first message arrives as
/start yclid_…; in MAX the parameter arrives when the client taps “Start”. The platform stores it on the lead:lead.click_idis the ID itself,lead.click_sourceis the network derived from the prefix (yandex,google,meta,tiktok,vk), andlead.start_paramis the raw value. Without a prefix the platform doesn't guess: a bare number, promo code or campaign tag is stored only instart_param, andclick_idstaysnull. The first click wins: repeated/startfrom the same lead changes nothing. In MAX the parameter arrives only on the bot's first launch — a client who has messaged the bot before will arrive via the ad link without aclick_id. - Subscribe to
lead.created, filter onchannel.type(telegram, whatsapp_baileys, max — orvoiceif the first contact was a call) and upload the conversion to your ad account bylead.click_idandoccurred_at. Without a click ID, you can still match oncontact.phone/contact.telegramthrough CRM conversion uploads.
How to tell the outcome of a call
A typical partner scenario: the call itself and its recording come from the partner's own telephony, and what they need from the platform is the result — did the agent transfer to a manager, close a deal, qualify the client on its own. Subscribe to lead.created, call.ended and lead.handoff and read:
| What you need to know | Where it is in the events |
|---|---|
| The client got in touch for the first time (messaged or called), which channel, which agent | lead.created: channel.type, channel.name, agent.name, contact, lead.type, lead.click_id |
| Transferred to a manager | call.ended: data.termination_reason = TRANSFERRED; lead.call_resolution = transferred |
| The client asked for a call-back | call.ended: data.outcome = callback_requested; lead.call_resolution = callback_requested; contact.callback_phone |
| Qualified / submitted a request | lead.handoff: data.reason (ready_to_buy, human_request, …); contact and lead blocks with qualification |
| No deal | call.ended: data.outcome ∈ not_interested | wrong_number | hung_up | no_answer and no lead.handoff for this lead.id |
| Duration, who hung up | call.ended: data.duration_seconds, data.termination_reason (USER_HANGUP, AGENT_HANGUP, …) |
| Summary in words | lead.summary in call.ended and lead.handoff |
Match with your telephony records by contact.phone (the number that called or was dialed) and time: occurred_at of lead.created for the first call, occurred_at and data.duration_seconds of call.ended; data.voice_call_id is the platform's internal ID, not the SIP Call-ID. All events of one lead carry the same lead.id; lead.handoff arrives a few seconds after call.ended.
Authentication
Every request carries an Authorization: Bearer <secret> header. The secret (a UUID) is generated in the dashboard when you connect. Compare it with your own using a constant-time comparison and reject the request on mismatch.
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);
}Headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| Authorization | Bearer <secret> — always |
| X-SWS-Event | event type, e.g. lead.created |
| X-SWS-Event-Id | event UUID — the same on every retry (deduplication key) |
| X-SWS-Delivery | attempt number: 1 for the first, increments on retries |
Request body
A single JSON object: a common envelope plus the channel, agent, contact, lead, chat, actor and data blocks with the fields of the specific event (examples are in the catalogue above). Scalar fields are always present, null when the value is unknown. The contact / lead / channel / agent blocks reflect the state at delivery time (usually seconds after the event); the transitions themselves are in data.
| Field | Type | Description |
|---|---|---|
| event | string | Event type from the catalogue (plus webhook.test when testing the connection). |
| event_id | string (UUID) | Event identifier. Stable across all delivery retries — your deduplication key. |
| api_version | string | Contract version. Currently "1". |
| occurred_at | string (ISO-8601, UTC) | When the event happened. |
| sent_at | string (ISO-8601, UTC) | When this delivery attempt was sent. |
| organization_id | number | Organization ID in the platform. |
| demo_mode | null | "live" | null for a real lead; "live" — an agent test from the Preview tab (dry tests are not sent). In a live preview, contact.first_name is prefixed with “[Demo] ”. |
| channel | object | The event's channel: { type, id, name }. type is telegram, whatsapp_baileys, max, preview or voice; id and name identify the specific bot or trunk from your channel settings. |
| agent | object | null | The AI agent that handled the event: { id, name }. For a new inquiry — the agent assigned to that channel. |
| contact | object | null | id, first_name, last_name, phone, callback_phone, email, telegram, whatsapp, channel_user_id, channel_handle_label, company, role, channel. Synthetic preview IDs never arrive as a phone number. |
| lead | object | null | id, url, type, status, mode, is_hot, goal_reached, summary, channel, direction (inbound | outbound | null), call_resolution (transferred | callback_requested | null), click_id, click_source, start_param, created_at, qualification {5 BANT fields}, custom_attributes {key: value}. |
| lead.click_id | string | null | The ad click ID from the bot link (see the ad conversions section): the value after the yclid_, gclid_, fbclid_, ttclid_, rb_clickid_ or msclkid_ prefix. null if there is no prefix or the inquiry didn't come from an ad. |
| lead.direction | string | null | Who started the conversation the lead came from: inbound — the client called or wrote in, outbound — we called or wrote first. Set once and never changed. |
| lead.click_source | string | null | The ad network, derived from the parameter prefix: yandex, google, meta, tiktok, vk, microsoft. null if there is no prefix. |
| lead.start_param | string | null | The raw start parameter of the bot link, as received (up to 64 characters): yclid_…, a promo code, a campaign tag. |
| chat | object | null | { id } of the conversation. |
| actor | object | null | { type, id, name } — who triggered a system event: agent, system, manager. |
| data | object | Fields of the specific event — see the catalogue examples. message_id and content (a human-readable line from the lead timeline) are always present. |
Delivery, retries, ordering
- Asynchronous and reliable. The event is enqueued in the platform's queue (PostgreSQL) right in the database, by a trigger, together with the event record itself; a separate worker sends it. Neither the agent's reply nor the call waits for delivery or for enqueueing.
- Retries. On a network error, timeout,
408,429or5xx— exponential backoff from 30 seconds to 1 hour, up to 8 attempts. On3xxand other4xxthere is no retry — the event is marked as failed immediately (usually a wrong secret or URL). - At-least-once. A retry carries the same
event_idand an incrementedX-SWS-Delivery— deduplicate byevent_id. - Ordering is not guaranteed. Sort by
occurred_at; link events of one lead vialead.id. - What is not delivered. Event types you are not subscribed to; events from periods when the webhook was paused or not yet connected; dry agent tests from Preview.
- The history of recent deliveries with HTTP status, attempt count and error is visible in the dashboard.
The webhook.test event
On “Test connection” the platform sends a POST with the same headers but without event blocks. Respond with 2xx without creating anything.
{
"event": "webhook.test",
"event_id": "d3c1a2b4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"api_version": "1",
"occurred_at": "2026-09-14T10:00:00.000Z",
"sent_at": "2026-09-14T10:00:00.000Z",
"message": "Тестовое событие от Sales Workshop AI. Вебхук событий подключён."
}Versioning
api_version pins the contract version. Within a version, changes are additive only (new event types, new optional fields) — build your receiver to tolerate them.
FAQ
- How do I send a “messaged in a messenger” conversion to Yandex Direct?
- Subscribe to the lead.created event: it arrives once per inquiry and carries lead.click_id — the click ID from a bot link like t.me/<bot>?start=yclid_<yclid> (lead.click_source = yandex). Upload the conversion to Yandex Metrica or Direct by this ID.
- How do I know the AI agent transferred the call to a manager?
- In the call.ended event, data.termination_reason is TRANSFERRED, and in the lead block call_resolution is transferred.
- What if an event arrives twice?
- Delivery is at-least-once: a retry carries the same event_id and an incremented X-SWS-Delivery. Deduplicate by event_id and respond with 2xx.