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

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

HeaderValue
Content-Typeapplication/json
AuthorizationBearer <secret> — always
X-SWS-Eventevent type, e.g. lead.qualified
X-SWS-Event-Iddelivery UUID — the same on every retry (deduplication key)
X-SWS-Deliveryattempt 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

FieldTypeDescription
eventstringEvent type: lead.qualified, lead.updated (plus webhook.test when you test the connection).
event_idstring (UUID)Delivery ID. Stays the same across retries — use it as the deduplication key.
api_versionstringContract version. Currently "1".
sent_atstring (ISO-8601, UTC)When the request was sent.
organization_idnumberOrganization ID on the platform.
agentobject|nullThe AI agent that brought in the lead. null if it couldn't be determined.
agent.idnumberAgent ID — stable; route on it, not on the name.
agent.namestringAgent name as set on the platform. May change.
lead.idnumberLead ID on the platform — your upsert key.
lead.urlstringLink to the lead card on the platform.
lead.channelstring|nullChannel: telegram, whatsapp_baileys, voice, …
lead.typestringnew, re_engaged, upsell, renewal.
lead.statusstringnew, active, won, lost, no_response.
lead.is_hotbooleanHot lead.
lead.summarystring|nullShort summary of the conversation.
lead.next_stepstring|nullRecommended next step (best-effort).
lead.created_atstring|nullWhen the lead was created (ISO-8601).
lead.directionstring|nullWho 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_paramstring|nullThe 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_idstring|nullAd 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_sourcestring|nullAd network from the prefix: yandex, google, meta, tiktok, vk, microsoft. null when there is no prefix.
lead.qualification.*string|nullFive BANT fields: current_situation, volume, goals, timeline, budget_level.
lead.custom_attributesarrayThe organization's custom attributes. [] if none are configured.
contact.first_name / last_namestring|nullFirst / last name.
contact.phonestring|nullThe 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_phonestring|nullContact phone — the number the customer asked to be called back on. null if they didn't ask.
contact.emailstring|nullEmail.
contact.telegramstring|nullTelegram username with @.
contact.whatsappstring|nullWhatsApp ID (for the WhatsApp channel).
contact.channel_user_idstring|nullThe customer's ID in the channel they wrote from — for channels without a public handle (MAX, Telegram without an @username).
contact.channel_handle_labelstring|nullHuman-readable name of that ID: MAX, Telegram ID, WhatsApp.
updated_fieldsstring[]What changed. Empty in lead.qualified, filled in lead.updated.
client_messagestring|nullThe customer's verbatim message that triggered lead.updated. null in lead.qualified.
contact.companystring|nullCompany.
contact.rolestring|nullRole: decision_maker, influencer, executor, unknown.
contact.channelstringContact'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

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.