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
- Open Account settings -> Webhooks and click Add endpoint.
- 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.executedcan 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.submittedevent only; every other event you subscribe to (leads, contact requests, conversations, live chat, AI actions) is delivered regardless of this setting.
- 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
eventTypematching your selection (or aswebhook.testfor 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 theX-ChatLab-Eventheader.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_UPDATEorUPDATE_CLIENT_CONTEXT(edited on the ChatLab side). Submissions of the human support form never fire this event - they firecontact_form.submittedinstead.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, andformCodeName/formNameidentify the form. With the classic lead form both arenullandfieldsis an empty array. - Each entry in
fieldsis{name, value, type}.nameis the field's technical name, stable across label edits - use it for mapping into your CRM. - For
multichoicefieldsvalueis an array of the selected options. Checkbox fields are individual entries with"true"/"false"values. - For
filefieldsvalueis 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 isnullwhen the phone field is off or the visitor left it empty. Custom form deliveries do not include a top-levelphone- a phone number collected by a custom form arrives as an entry infields.source-CUSTOM_FORMwhen a custom form backs the contact form,CONTACT_FORMfor the built-in one.formCodeName/formName- identify the custom form behind the request; both arenullfor 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 aslead.created). With the built-in contact formemail,messageandphoneare set (phoneonly when the phone field is on and the visitor filled it in, otherwisenull),fieldsis an empty array and the form identifiers arenull. 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.formNameis the display label shown to visitors.fieldsuses the same{name, value, type}entries aslead.created: multichoice values are arrays, file values are download links.purpose-STANDALONE,LEAD_COLLECTIONorHUMAN_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.nullif the conversation was opened without message content.chatSource- the channel the conversation came in on:WIDGET,WHATSAPP,MESSENGER,VOICE,VOICE_PHONE,API,BOOKING,AIRBNBorIDOBOOKING.byAdmin-truewhen 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,nullwhen it could not be determined.ipAddress- the visitor's IP address as seen by ChatLab,nullwhen 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-POSITIVEorNEGATIVE. 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 exampleen_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 isnullfor 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,countryCodeandipAddress. Every key is always present; unknown values arenull.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 alwaysAI, 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 onAI.- 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": {}
}
datais intentionally empty. Everything you need is in the envelope:botIdidentifies the chatbot andconversationId/sessionIdtie the event to the conversation. You may receive it without an earlierlive_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 examplesearch_productsfor a managed integration or the name you gave a custom API action.status-SUCCESSorERROR.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 whenstatusisERROR;nullotherwise.
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 awebhook.testdelivery 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
2xxwithin 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_OPENentries 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
2xxat 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 theX-ChatLab-Deliveryheader).
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
- Lead collection - the form behind
lead.created - Human Support Contact form - the form behind
contact_form.submitted - Live Chat - the flow behind the
live_chat.*events - Conversation rating - the thumbs up/down behind
conversation.rated - AI Actions - the integrations behind
ai_action.executed - Chat API - in-browser widget callbacks (client-side counterpart to webhooks)
- Management API - REST API for bot management and usage data