Chat API

Webhooks

Last updated: August 13, 2026

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: webhooks are available from the Standard plan up (feature: Webhooks).

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 a filtered subset.

Setting up an endpoint

  1. Open Account settings -> Webhooks and click Create 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) - restrict the endpoint to specific bots. Leave empty to receive events from 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. Update your receiver before rolling, or deliveries will fail signature verification on your side.
  • 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 event occurred.
  • 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.

{
  "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.
  • 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 only email and message are populated, 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, and deduplicate by conversationId + timestamp if you subscribe to both.

{
  "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 rates a bot reply with thumbs up or down (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"
  }
}
  • summary - the generated summary text. Summaries are produced a few minutes after the conversation goes idle, so this event arrives later than the rest of the conversation events.
  • language - ISO code of the language the summary was written in, following the 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, so this event follows conversation.summarized for the same conversation. Clients are identified by e-mail, which is why the address is repeated at the top level of data.

{
  "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.
  • The event says a handover was requested, not that an operator picked it up. Wait for live_chat.started for that.

live_chat.started

Fires when an operator joins and the live chat session actually begins.

{
  "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 already received live_chat.requested for.

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 - how long the operator was in the conversation, counted from the moment the session started. 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 (!signature || !timestamp) 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 + '.' + 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 === '') {
        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 guarantees before building on webhooks:

  • Respond fast. Your endpoint must respond within 3 seconds or the delivery counts as failed. Respond 2xx immediately and process the payload asynchronously (queue it, then acknowledge) - do not do CRM calls or database writes before answering.
  • Fire-and-forget, at-most-once. Each event gets exactly one delivery attempt - there are no retries. 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: when you need guaranteed completeness, reconcile against the Management API or your lead exports.
  • Circuit breaker. After 5 consecutive failed deliveries for a bot, deliveries for that bot pause for 5 minutes. Events occurring during the pause are dropped, and the delivery log shows CIRCUIT_OPEN entries so you can see exactly when and why traffic was suppressed. 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).

Limits

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

Related articles