Webhook: deliver leads to your CRM
When the AI agent has qualified a lead and hands it over to a manager, the platform sends a single HTTP POST with the lead's data as JSON to your endpoint. You map the fields into your CRM on your side.
Endpoint requirements
https://only.- Respond with
2xxwithin ~10 seconds. - Idempotency: deduplicate on
lead.id(see “Delivery”).
Authentication
The platform signs every request with a secret in the Authorization: Bearer <secret> header. The secret (a UUID) is generated automatically when you connect the destination in your account — copy it from there and store it on your side. Your endpoint compares the value with its own copy (using a constant-time comparison) and rejects the request if they don't match. Always use 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 only — constant-time string
import os # comparison; there is no HMAC signature here
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)All three examples do the same thing: compare your stored secret with the token from the header as a plain string. hash_equals / timingSafeEqual / hmac.compare_digest are a “safe ==” (constant-time comparison that protects against timing attacks), not a cryptographic signature.
Headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| Authorization | Bearer <secret> — always |
| X-SWS-Event | event type, e.g. lead.qualified |
| X-SWS-Event-Id | delivery UUID — the same on every retry (deduplication key) |
| X-SWS-Delivery | attempt number: 1 for the first one, incremented on retries |
Request body
A single JSON object. Scalar fields are always present, null when the value is unknown. custom_attributes is an array ([] if there are no attributes). A voice lead arrives with the same payload as a chat lead.
Example
{
"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": "Fitness — chat"
},
"lead": {
"id": 461,
"url": "https://salesworkshop.ai/leads/461",
"channel": "telegram",
"type": "new",
"status": "active",
"is_hot": true,
"summary": "Interested in an annual membership at the Downtown club, asked about the pool and paying in installments. Ready for a trial workout at the weekend.",
"next_step": "Book a trial workout on Saturday",
"created_at": "2026-07-16T08:40:00.000Z",
"direction": "inbound",
"click_id": null,
"click_source": null,
"start_param": "summer_promo",
"qualification": {
"current_situation": "Goes to a gym near home, unhappy with the equipment",
"volume": "Annual membership, 1 person",
"goals": "Lose weight by summer, needs a pool",
"timeline": "Ready to buy this week",
"budget_level": "up to 40,000 RUB"
},
"custom_attributes": [
{
"key": "club",
"label": "Club",
"type": "ENUM",
"is_multiple": false,
"value": "Downtown"
},
{
"key": "membership",
"label": "Membership",
"type": "STRING",
"is_multiple": false,
"value": "Annual"
},
{
"key": "promised_budget",
"label": "Budget",
"type": "MONEY",
"is_multiple": false,
"value": 40000
},
{
"key": "trial_date",
"label": "Trial date",
"type": "DATE",
"is_multiple": false,
"value": "2026-07-18"
},
{
"key": "interests",
"label": "Interests",
"type": "STRING",
"is_multiple": true,
"value": [
"pool",
"sauna",
"group classes"
]
}
]
},
"contact": {
"first_name": "John",
"last_name": "Smith",
"phone": "+79991234567",
"callback_phone": "+79995554433",
"email": null,
"telegram": "@john_smith",
"whatsapp": null,
"channel_user_id": null,
"channel_handle_label": null,
"company": null,
"role": "unknown",
"channel": "telegram"
},
"updated_fields": [],
"client_message": null
}Field reference
| Field | Type | Description |
|---|---|---|
| event | string | Event type: lead.qualified, lead.updated (plus webhook.test when you test the connection). |
| event_id | string (UUID) | Delivery ID. Stays the same across retries — use it as the deduplication key. |
| api_version | string | Contract version. Currently "1". |
| sent_at | string (ISO-8601, UTC) | When the request was sent. |
| organization_id | number | Organization ID on the platform. |
| agent | object|null | The AI agent that brought in the lead. null if it couldn't be determined. |
| agent.id | number | Agent ID — stable; route on it, not on the name. |
| agent.name | string | Agent name as set on the platform. May change. |
| lead.id | number | Lead ID on the platform — your upsert key. |
| lead.url | string | Link to the lead card on the platform. |
| lead.channel | string|null | Channel: 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 | Hot lead. |
| lead.summary | string|null | Short summary of the conversation. |
| lead.next_step | string|null | Recommended next step (best-effort). |
| lead.created_at | string|null | When the lead was created (ISO-8601). |
| 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.start_param | string|null | The start parameter of the bot link (t.me/<bot>?start=…, in MAX — max.ru/<bot>?start=…), as received: up to 64 characters of A-Z, a-z, 0-9, _ and -. Any tag of your own lands here — a custom id, a promo code, a campaign tag. null when the client did not come through such a link or not from Telegram/MAX. |
| lead.click_id | string|null | Ad click id: the value after a yclid_, gclid_, fbclid_, ttclid_, rb_clickid_ or msclkid_ prefix in start_param. null when there is no such prefix. |
| lead.click_source | string|null | Ad network from the prefix: yandex, google, meta, tiktok, vk, microsoft. null when there is no prefix. |
| lead.qualification.* | string|null | Five BANT fields: current_situation, volume, goals, timeline, budget_level. |
| lead.custom_attributes | array | The organization's custom attributes. [] if none are configured. |
| contact.first_name / last_name | string|null | First / last name. |
| contact.phone | string|null | The number the customer reached us from (caller ID of an inbound call, or the number we dialed). Never changes. null for chat contacts. |
| contact.callback_phone | string|null | Contact phone — the number the customer asked to be called back on. null if they didn't ask. |
| contact.email | string|null | Email. |
| contact.telegram | string|null | Telegram username with @. |
| contact.whatsapp | string|null | WhatsApp ID (for the WhatsApp channel). |
| contact.channel_user_id | string|null | The customer's ID in the channel they wrote from — for channels without a public handle (MAX, Telegram without an @username). |
| contact.channel_handle_label | string|null | Human-readable name of that ID: MAX, Telegram ID, WhatsApp. |
| updated_fields | string[] | What changed. Empty in lead.qualified, filled in lead.updated. |
| client_message | string|null | The customer's verbatim message that triggered lead.updated. null in lead.qualified. |
| contact.company | string|null | Company. |
| contact.role | string|null | Role: decision_maker, influencer, executor, unknown. |
| contact.channel | string | Contact's channel. |
The lead.updated event
Customers often share a phone number or messenger handle after the lead has already been sent to you: the bot asks for contact details at the moment of handoff, and the person replies a few seconds later. In that case the platform sends a second lead.updated event with the same lead.id: the contact block already contains the new data, updated_fields lists the fields that changed, and client_message holds the customer's message verbatim. Update the existing deal by lead.id rather than creating a new one.
{
"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",
"…": "same fields as in lead.qualified"
},
"contact": {
"first_name": "Kate",
"phone": null,
"callback_phone": null,
"telegram": "@kate_cue",
"whatsapp": null,
"channel_user_id": "337709712",
"channel_handle_label": "MAX",
"channel": "max",
"…": "same fields as in lead.qualified"
},
"updated_fields": [
"first_name",
"telegram"
],
"client_message": "@kate_cue"
}The webhook.test event
When you click “Test connection”, the platform sends a POST with the same headers (including Authorization: Bearer) but without the lead field. Your receiver must branch on the event field: process the lead only when event == "lead.qualified", update it when event == "lead.updated", and answer any other event (including webhook.test and future types) with 2xx without creating a deal — otherwise the connection test fails or creates an empty deal. The message text of the test event is always in Russian (“Test event from Sales Workshop AI. The webhook connection works.”).
{
"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 работает."
}Delivery and idempotency
- Best-effort, at-least-once. The request is sent synchronously on handoff. On a network error, a timeout,
429or5xx— up to 2 retries (with 1s and 3s pauses). Other4xxresponses are not retried. Each attempt times out after ~10 seconds. - Deduplication. All retries of one delivery carry the same
event_id— treat a retry as a duplicate. - Upsert on
lead.id. Different events for the same lead arrive with differentevent_ids but the samelead.id. - Delivery errors are shown in the platform UI and don't affect the AI agent.
Custom attributes
lead.custom_attributes is an array of { key, label, type, is_multiple, value }. type ∈ STRING, NUMBER, MONEY, DATE, BOOLEAN, ENUM; value uses the native JSON type (MONEY → a number of rubles, DATE → YYYY-MM-DD, BOOLEAN → boolean, ENUM/STRING → string). Keep the key → CRM field mapping on your side.
Multi-value attributes: with is_multiple: true, value arrives as an array of items of the same base type, e.g. { "key": "interests", "type": "STRING", "is_multiple": true, "value": ["pool", "sauna"] }. type stays the base type; with is_multiple: false, value is a scalar. Branch your handling on the is_multiple flag.
Versioning
api_version pins the contract version. Within a version, changes are additive only — make your receiver tolerant of new optional fields.