Help Center
Chat API

Webhooks

Last updated:

Webhooks overview

Webhooks let ChatLab notify your systems the moment something happens in your chatbots. Instead of polling the Management API or exporting data manually, you register an HTTPS endpoint and ChatLab sends a signed HTTP POST to it in real time - when a visitor leaves a lead, submits a contact form, rates a conversation, requests a human, or when an AI action runs.

Typical uses:

  • push new leads straight into your CRM the second they are captured
  • notify your team in Slack when a visitor requests live chat
  • feed conversation ratings and summaries into your own analytics
  • monitor AI action executions and alert on errors

Availability: your account must include the Webhooks feature.

Where to configure: in the admin app, open Account settings -> Webhooks (right next to the Management API section). Webhooks are account-level - one endpoint can receive events from all your bots or from one selected bot.

Setting up an endpoint

  1. Open Account settings -> Webhooks and click Add endpoint.
  2. Fill in the endpoint form:
    • Name - a label for your own reference, e.g. "CRM sync" or "Slack alerts".
    • URL - the HTTPS address ChatLab will POST events to.
    • Events - select which event types this endpoint receives (see the catalog below). Select only what you need; high-volume events like ai_action.executed can generate a lot of traffic.
    • Bot filter (optional) - select one bot, or All bots for all bots on your account.
    • Custom form filter (optional) - routes submissions of one custom form to this endpoint. It narrows the custom_form.submitted event only; every other event you subscribe to (leads, contact requests, conversations, live chat, AI actions) is delivered regardless of this setting.
  3. Submit. The endpoint secret is shown exactly once in the success dialog - copy it now and store it securely. You will need it to verify signatures (see Security below). The plaintext cannot be retrieved later.

Each endpoint also has:

  • Enable/disable toggle - pause deliveries without deleting the endpoint. Disabled endpoints silently drop events (they are not queued for later).
  • Send sample event - delivers a signed test request to your URL so you can verify your receiver end to end. You can pick the event type and edit the sample values before sending, so your handler sees realistic data. The test arrives as a regular delivery with eventType matching your selection (or as webhook.test for a plain connectivity check).
  • Roll secret - generates a new secret and invalidates the old one. Use this if the secret may have leaked. The new secret is again shown only once. For a controlled rotation, pause the endpoint, roll and copy the new secret, update the receiver, then re-enable and send a sample event. Events during the pause are not queued. Plan this interruption before rotating.
  • Delivery log - a per-endpoint list of recent deliveries with timestamp, event type, HTTP status returned by your server, and response time. Failed deliveries and circuit breaker pauses are visible here. The log is retained for 14 days.

Event envelope

Every delivery is an HTTP POST with Content-Type: application/json. The body always has the same envelope; the data object is specific to the event type:

{
  "eventId": "9f1c1c8e-6a2b-4b9e-9d2f-3f8a1e2b4c5d",
  "eventType": "lead.created",
  "timestamp": "2026-08-13T14:22:31Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": { }
}
  • eventId - unique per event. Use it for deduplication if your processing must be idempotent.
  • eventType - one of the types documented below; also sent in the X-ChatLab-Event header.
  • timestamp - ISO 8601 UTC time the delivery envelope was prepared.
  • botId / botName - the bot the event belongs to.
  • conversationId / sessionId - the conversation context, when applicable.

Event catalog

lead.created

Fires when a visitor submits their contact details - through the lead collection form, the live chat pre-form, or a custom form used for lead collection.

{
  "eventId": "9f1c1c8e-6a2b-4b9e-9d2f-3f8a1e2b4c5d",
  "eventType": "lead.created",
  "timestamp": "2026-08-13T14:22:31Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "email": "jane.doe@example.com",
    "name": "Jane Doe",
    "phone": "+1 555 0123",
    "source": "LEAD_COLLECTION_FORM",
    "formCodeName": "lead_form",
    "formName": "Lead form",
    "fields": [
      {"name": "email", "value": "jane.doe@example.com", "type": "email"},
      {"name": "company", "value": "Acme Inc.", "type": "text"},
      {"name": "topics", "value": ["Billing", "Delivery"], "type": "multichoice"},
      {"name": "attachment", "value": "https://api.chatlab.com/aichat/customform/download?key=...&token=...", "type": "file"}
    ],
    "pageUrl": "https://acme.com/pricing"
  }
}
  • source - how the contact details were captured: LEAD_COLLECTION_FORM (lead collection form), LIVE_CHAT_FORM (live chat pre-form), CONVERSATION (the AI picked the details up during the chat), ADMIN_DATA_UPDATE or UPDATE_CLIENT_CONTEXT (edited on the ChatLab side). Submissions of the human support form never fire this event - they fire contact_form.submitted instead.
  • email, name, phone - the contact details mapped onto the lead record.
  • When a custom form is used for lead collection, every field defined on that form is included in fields, in form order, and formCodeName / formName identify the form. With the classic lead form both are null and fields is an empty array.
  • Each entry in fields is {name, value, type}. name is the field's technical name, stable across label edits - use it for mapping into your CRM.
  • For multichoice fields value is an array of the selected options. Checkbox fields are individual entries with "true" / "false" values.
  • For file fields value is a download link to the uploaded file; the webhook never carries file contents.
  • pageUrl - the page the visitor was on when they submitted.

contact_form.submitted

Fires when a visitor submits the human support contact form or a custom form used for human contact.

With the built-in contact form:

{
  "eventId": "e4a6c8d0-2b5f-4c7e-9a1b-3d6f8a0c2e4b",
  "eventType": "contact_form.submitted",
  "timestamp": "2026-08-13T14:25:02Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "email": "jane.doe@example.com",
    "phone": "+1 555 0123",
    "message": "I need help with my last invoice.",
    "source": "CONTACT_FORM",
    "formCodeName": null,
    "formName": null,
    "fields": []
  }
}

With a custom form used for human contact:

{
  "eventId": "3a7b9c2d-1e4f-4a6b-8c0d-5e2f7a9b1c3d",
  "eventType": "contact_form.submitted",
  "timestamp": "2026-08-13T14:25:02Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "email": "jane.doe@example.com",
    "message": "I need help with my last invoice.",
    "source": "CUSTOM_FORM",
    "formCodeName": "contact_form",
    "formName": "Contact form",
    "fields": [
      {"name": "email", "value": "jane.doe@example.com", "type": "email"},
      {"name": "order_number", "value": "A-10293", "type": "text"},
      {"name": "message", "value": "I need help with my last invoice.", "type": "textarea"}
    ]
  }
}
  • email - the address the visitor left, and the one your support team should reply to.
  • phone - built-in form only: the phone number the visitor entered, as a string with surrounding spaces removed. It is filled in only when you turned on Ask for a phone number (optional field) in the contact form settings and the visitor typed a number. The field is optional for the visitor, so it is null when the phone field is off or the visitor left it empty. Custom form deliveries do not include a top-level phone - a phone number collected by a custom form arrives as an entry in fields.
  • source - CUSTOM_FORM when a custom form backs the contact form, CONTACT_FORM for the built-in one.
  • formCodeName / formName - identify the custom form behind the request; both are null for the built-in form.
  • When a custom form is used, every field defined on that form is included in fields (same {name, value, type} format as lead.created). With the built-in contact form email, message and phone are set (phone only when the phone field is on and the visitor filled it in, otherwise null), fields is an empty array and the form identifiers are null.
  • message - the mapped message field, or all filled values joined together when the form defines no message field.

custom_form.submitted

Fires for every custom form submission, regardless of the form's purpose. Note that forms whose purpose is lead collection or human contact also fire their dedicated lead.created / contact_form.submitted event - subscribe to one or the other depending on whether you want the generic or the specialized view, rather than creating duplicate business actions. The two event families have different event IDs; conversationId plus timestamp is not a reliable submission identifier.

{
  "eventId": "6c1d8e3f-2a5b-4c7d-9e0f-1a4b6c8d0e2f",
  "eventType": "custom_form.submitted",
  "timestamp": "2026-08-13T14:27:45Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "formCodeName": "warranty_claim",
    "formName": "Warranty claim",
    "fields": [
      {"name": "order_number", "value": "A-10293", "type": "text"},
      {"name": "issue", "value": "Damaged on arrival", "type": "textarea"},
      {"name": "photo", "value": "https://api.chatlab.com/aichat/customform/download?key=...&token=...", "type": "file"}
    ],
    "purpose": "STANDALONE"
  }
}
  • formCodeName - the stable machine name of the form, unchanged when you rename the form; use it to route submissions in your own system. formName is the display label shown to visitors.
  • fields uses the same {name, value, type} entries as lead.created: multichoice values are arrays, file values are download links.
  • purpose - STANDALONE, LEAD_COLLECTION or HUMAN_CONTACT, depending on how the form is wired to the chatbot.

conversation.started

Fires when a visitor sends the first message of a new conversation.

{
  "eventId": "8e2f0a4b-3c6d-4e8f-a1b2-2c5d7e9f1a3b",
  "eventType": "conversation.started",
  "timestamp": "2026-08-13T14:20:11Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "firstMessage": "Do you ship to Canada?",
    "chatSource": "WIDGET",
    "byAdmin": false,
    "countryCode": "PL",
    "ipAddress": "83.12.44.7"
  }
}
  • firstMessage - the exact text of the visitor's opening message. null if the conversation was opened without message content.
  • chatSource - the channel the conversation came in on: WIDGET, WHATSAPP, MESSENGER, VOICE, VOICE_PHONE, API, BOOKING, AIRBNB or IDOBOOKING.
  • byAdmin - true when the conversation comes from the chatbot preview inside the ChatLab admin panel rather than from a real visitor. Use it to keep your own test chats out of your CRM.
  • countryCode - ISO country code resolved from the visitor's IP address, null when it could not be determined.
  • ipAddress - the visitor's IP address as seen by ChatLab, null when unavailable. Treat it as personal data under GDPR and store it only if you have a lawful basis.

conversation.rated

Fires when a visitor submits a positive or negative conversation rating (see Conversation rating).

{
  "eventId": "1b4c6d8e-5f0a-4b2c-8d3e-4f7a9b1c3d5e",
  "eventType": "conversation.rated",
  "timestamp": "2026-08-13T14:31:09Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "rating": "POSITIVE"
  }
}
  • rating - POSITIVE or NEGATIVE. Clearing a rating does not fire the event, so you never receive a neutral value.

conversation.summarized

Fires when ChatLab generates a summary of a finished conversation.

{
  "eventId": "4d7e9f1a-6b2c-4d4e-9f0a-5b8c0d2e4f6a",
  "eventType": "conversation.summarized",
  "timestamp": "2026-08-13T14:45:00Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "summary": "Visitor asked about shipping to Canada and delivery times. The bot confirmed availability and quoted 5-7 business days. Visitor left satisfied.",
    "language": "en_US"
  }
}
  • summary - the generated summary text. Summary generation is asynchronous and depends on the bot's summary configuration and processing schedule; do not assume a fixed delivery delay.
  • language - the bot's internal locale passed to summarization, for example en_US; do not assume this matches the visitor's conversation language.

client.summarized

Fires when ChatLab refreshes the AI profile of a client. The profile is rebuilt from the previous profile plus the summary of the conversation that just ended, and may be generated after conversation.summarized. Delivery order is not guaranteed. The payload includes an email when one is known, but ChatLab can also hold clients without an email.

{
  "eventId": "b5d8f1a3-7c2e-4d9b-a6f0-1e3c5a7b9d2f",
  "eventType": "client.summarized",
  "timestamp": "2026-08-18T09:12:04Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "clientEmail": "jane.doe@example.com",
    "client": {
      "email": "jane.doe@example.com",
      "name": "Jane Doe",
      "phone": "+1 555 0123",
      "countryCode": "PL",
      "ipAddress": "83.12.44.7"
    },
    "clientSummary": "Returning customer interested in international shipping. Asked about delivery times to Canada twice and about return costs once."
  }
}
  • clientEmail - the identifier to match the client against your own CRM. It is null for anonymous visitors who never left an address, and the event still fires for them - skip those deliveries if your integration is keyed on e-mail.
  • client - the contact record ChatLab holds for this person: email, name, phone, countryCode and ipAddress. Every key is always present; unknown values are null.
  • clientSummary - the full profile text as plain text, not a diff. It replaces whatever the previous summary was, so store it as an overwrite rather than appending.
  • The profile is rebuilt only for bots with chat memory enabled, and only for conversations that were idle long enough to be summarized - expect this event minutes after the conversation ends, not immediately.

live_chat.requested

Fires when the AI hands the conversation over to live chat, either because the visitor asked for a human or because the bot decided a human was needed.

{
  "eventId": "7a0b2c4d-8e3f-4a5b-b0c1-6d9e1f3a5b7c",
  "eventType": "live_chat.requested",
  "timestamp": "2026-08-13T14:33:20Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "requestedBy": "AI"
  }
}
  • requestedBy - currently always AI, because the handoff is always raised by the bot's live chat action, including when the visitor asks for it in plain words. Treat it as an open enum: handle unknown values instead of asserting on AI.
  • This event covers the AI action requesting handoff, not every way a visitor can open live chat. It does not prove that an operator joined.

live_chat.started

Fires when the live-chat session is created after the visitor's handoff form is submitted. This happens before an operator necessarily joins or replies; do not treat it as proof of human engagement.

{
  "eventId": "0c3d5e7f-9a4b-4c6d-a1b2-7e0f2a4b6c8d",
  "eventType": "live_chat.started",
  "timestamp": "2026-08-13T14:33:55Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {}
}
  • data is intentionally empty. Everything you need is in the envelope: botId identifies the chatbot and conversationId / sessionId tie the event to the conversation. You may receive it without an earlier live_chat.requested, for example when the visitor used the widget's live-chat control.

live_chat.ended

Fires when the live chat session ends.

{
  "eventId": "2e5f7a9b-0c5d-4e7f-b2c3-8f1a3b5c7d9e",
  "eventType": "live_chat.ended",
  "timestamp": "2026-08-13T14:52:41Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "durationSeconds": 1126
  }
}
  • durationSeconds - elapsed live-chat session duration, counted from session creation, including time waiting for an operator. The field is omitted in the rare case where a session ends without ever having been started.

ai_action.executed

Fires every time the bot executes an AI action - a managed integration call or a custom API function. This is a high-volume event: an active e-commerce bot can execute hundreds of actions per day, and a single visitor turn can trigger several. Subscribe to it on a dedicated endpoint, or be sure your receiver can absorb the volume.

{
  "eventId": "5f8a0b2c-1d6e-4f8a-c3d4-9a2b4c6d8e0f",
  "eventType": "ai_action.executed",
  "timestamp": "2026-08-13T14:21:03Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "actionName": "search_products",
    "status": "SUCCESS",
    "durationMs": 842,
    "errorMessage": null
  }
}
  • actionName - the name of the executed action as the AI sees it, for example search_products for a managed integration or the name you gave a custom API action.
  • status - SUCCESS or ERROR.
  • durationMs - how long the action took, in milliseconds. Useful for spotting a slow integration before visitors complain about it.
  • errorMessage - the failure reason, populated only when status is ERROR; null otherwise.

webhook.test

Sent by the Send sample event button when you run a plain connectivity check. Signed exactly like a real event.

{
  "eventId": "9b2c4d6e-3f8a-4b0c-d5e6-0b3c5d7e9f1a",
  "eventType": "webhook.test",
  "timestamp": "2026-08-13T14:10:00Z",
  "botId": 1234,
  "botName": "Support Bot",
  "conversationId": "conv_a1b2c3",
  "sessionId": "sess_x9y8z7",
  "data": {
    "message": "Test delivery from ChatLab"
  }
}
  • message - fixed text, always the same. The envelope fields carry sample values, so never treat a webhook.test delivery as real data.
  • This is the only event type you cannot subscribe to on an endpoint: it is sent on demand from the admin panel and always reaches the endpoint you clicked, no matter which events it listens to.

Security: verifying deliveries

Every delivery carries four headers:

Header Value
X-ChatLab-Signature sha256=<hex hmac> - HMAC-SHA256 signature of the payload
X-ChatLab-Timestamp Unix time in seconds when the delivery was signed
X-ChatLab-Event The event type, e.g. lead.created
X-ChatLab-Delivery Unique delivery id, equal to the body's eventId

The signature is computed as HMAC-SHA256 over the string {timestamp}.{rawBody} using your endpoint secret, where {timestamp} is the value of X-ChatLab-Timestamp and {rawBody} is the raw, unparsed request body. Always verify against the raw bytes - re-serializing parsed JSON will change the byte sequence and break the signature.

To protect against replay attacks, reject deliveries whose X-ChatLab-Timestamp is older than 5 minutes.

Node.js

const crypto = require('crypto');

function verifyChatLabSignature(req, secret) {
    const signature = req.headers['x-chatlab-signature'];
    const timestamp = req.headers['x-chatlab-timestamp'];
    if (typeof signature !== 'string' || typeof timestamp !== 'string') return false;
    if (!/^\d+$/.test(timestamp) || !Number.isSafeInteger(Number(timestamp))) return false;
    if (!Buffer.isBuffer(req.rawBody)) return false;

    // Reject stale deliveries (older than 5 minutes)
    const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (ageSeconds > 300) return false;

    // rawBody must be the raw request body bytes, not re-serialized JSON.
    // With Express: app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }))
    const expected = 'sha256=' + crypto
        .createHmac('sha256', secret)
        .update(timestamp + '.')
        .update(req.rawBody)
        .digest('hex');

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

PHP

<?php
function verifyChatLabSignature(string $secret): bool
{
    $signature = $_SERVER['HTTP_X_CHATLAB_SIGNATURE'] ?? '';
    $timestamp = $_SERVER['HTTP_X_CHATLAB_TIMESTAMP'] ?? '';
    if ($signature === '' || $timestamp === '' || !ctype_digit($timestamp)) {
        return false;
    }

    // Reject stale deliveries (older than 5 minutes)
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $rawBody = file_get_contents('php://input');
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    return hash_equals($expected, $signature);
}

If verification fails, respond with 401 and discard the payload. Never process unverified deliveries - anyone who discovers your URL can POST arbitrary JSON to it.

Delivery behavior

Understand these delivery rules before building on webhooks:

  • Respond fast. Your endpoint must respond within 3 seconds or the delivery counts as failed. Verify the signature, durably enqueue the event, and acknowledge with 2xx within that window. Perform slower CRM calls and business processing asynchronously.
  • Fire-and-forget, at-most-once. Matching enabled endpoints get at most one delivery attempt - there are no retries. Paused endpoints and open circuits can suppress even that attempt. If your endpoint is down, times out, or returns a non-2xx status, that event is gone and will not be redelivered. Webhooks are notifications, not a replicated data store: use Bot Talk API conversation endpoints or lead exports to reconcile retained data where supported. Management API covers bot settings and usage, not a complete event archive. Some events cannot be reconstructed through these interfaces.
  • Circuit breaker. After 5 consecutive failed deliveries for an endpoint/bot pair, deliveries for that pair pause for 5 minutes. Events occurring during the pause are dropped, and the delivery log shows CIRCUIT_OPEN entries to identify suppressed attempts. Deliveries skipped by an open circuit do not count towards auto-disable.
  • Auto-disable. The check runs at the moment a delivery fails, never on a timer. If a delivery fails and there has been no successful delivery for 7 days - counted from the last success, or from the endpoint's creation date if it has never succeeded - the endpoint is switched off and you receive an email notification. A single 2xx at any point resets that clock. An endpoint that receives no traffic is never disabled, because nothing fails. Re-enable it from Account settings once your receiver is fixed; the failure counter and the auto-disable stamp are cleared when you switch it back on, and events missed while it was off are not backfilled.
  • 410 Gone. If your endpoint responds with HTTP 410 Gone, ChatLab disables it immediately. Use this to programmatically decommission an endpoint from the receiving side.
  • Idempotency. Duplicate deliveries are not expected in normal operation, but if your processing must be strictly idempotent, deduplicate by eventId (also available in the X-ChatLab-Delivery header).

Delivery order is not guaranteed. Store the event ID and make business processing idempotent. Keep your own retention and access controls for webhook payloads, which may contain personal data and file-download links.

The signature examples use Node.js crypto and PHP's hash_equals. Capture the raw request body before parsing it.

Limits

  • Up to 10 webhook endpoints per account.
  • Delivery log retention: 14 days. Older entries are removed automatically.

Related articles