# Webhooks

Canonical: https://www.chat-data.com/api-reference/webhooks

Webhooks push Chat Data events to your own HTTP or HTTPS endpoint (HTTPS recommended) as they happen, so you don't have to poll.

| Event | Fires when |
| --- | --- |
| `chat` | A chat turn completes. |
| `live-chat-escalation` | A visitor asks for a human agent. |
| `lead-submission` | A visitor submits the lead form. |
| `user-online` | A visitor joins a conversation that is in live chat. |
| `user-offline` | A visitor leaves a conversation that is in live chat. |

Register an endpoint with [Add a webhook](/api-reference/add-webhook) and remove it with [Delete a webhook](/api-reference/delete-webhook), or manage webhooks in the chatbot's settings ([guide](https://cookbook.chat-data.com/docs/webhooks-setup)). The response lists each webhook with its `secret`; keep it on your server.

## What a delivery looks like

Each event is a `POST` to your URL with a JSON body and these headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `text/plain` by default, or `application/json` when the webhook was added with `contentType: application/json` (Make and n8n endpoints always get `text/plain`). The body is JSON either way. |
| `x-chat-data-topic` | The event name, e.g. `chat` |
| `x-chat-data-hmac-sha256` | Base64 HMAC-SHA256 of the raw request body, keyed with the webhook's `secret` |

## Verify that a delivery came from Chat Data

Compute the HMAC over the **raw** body (before JSON parsing) and compare it to the header in constant time.

**Node.js**

```javascript
import crypto from 'node:crypto';

// Express: app.post('/chat-data-webhook', express.raw({ type: '*/*' }), handler)
function isFromChatData(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
import base64, hashlib, hmac

def is_from_chat_data(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = base64.b64encode(hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()).decode()
    return hmac.compare_digest(expected, signature_header or "")
```

## Respond, retries and disabling

- Reply with any `2xx` status as soon as you have the event, and do slow work afterwards.
- A failed delivery (a network error, a timeout, or a `4xx` other than `404`/`410`) is retried once, 5 seconds later.
- `404` and `5xx` responses are not retried.
- `410 Gone` turns the webhook off immediately. After 20 failed deliveries in a row it is also turned off. Once your endpoint is healthy, re-enable the webhook on the chatbot's **Settings → Webhooks** page, or delete it and add it again (adding the same URL again does not turn it back on).

## Event bodies

**`live-chat-escalation`** carries `chatbot_id`, `chatbot_name`, `chatbot_display_name`, `notification_email`, `handling_url` (where an agent takes over), `conversation_id`, `conversation_start_time`, `messages`, `source` and `lead`.

**`lead-submission`** carries `chatbot_id`, `chatbot_name`, `chatbot_display_name` and `lead` (the submitted `name`, `email`, `phone`, `source`, any custom form fields, and `uuid`). It has no `conversation_id`; pass `lead.uuid` as `leadId` to [Get conversations](/api-reference/get-conversations) to find the conversation.

**`user-online`** and **`user-offline`** carry `chatbot_id`, `chatbot_name`, `conversation_id` and, when known, the visitor's `name`, `email`, `phone`, `source` and `user_uuid`.

## The chat event body

| Field | Type | Description |
| --- | --- | --- |
| chatbot_id | string | ID of the chatbot that produced the response. |
| conversation_id | string | UUID of the conversation the message belongs to. |
| messages | array of object | Conversation messages preceding the response. |
| messages[].role | string | The role of the message sender (`user`, `assistant`, or `human` for a live chat agent). |
| messages[].content | string | The text content of the message. |
| messages[].isTranscript | boolean | Indicates whether the message is a transcript of the realtime voice-to-voice interaction. |
| messages[].upvote | boolean | Indicates whether the message is upvoted by the user. |
| messages[].name | string | The name of the agent who sent the message, if the role is `human`. |
| messages[].avatar | string | The avatar image URL of the agent who sent the message, if the role is `human`. |
| messages[].timestamp | string | When the message was sent (ISO 8601). |
| messages[].mid | string | The ID of the message. |
| messages[].reasoning | string | The model's reasoning for an assistant message, when it was recorded. |
| messages[].downvoteFeedback | string | The user's feedback when they downvoted the message. |
| messages[].read | boolean | Indicates whether the message has been read by the user or the agent. |
| messages[].files | array of object | The files attached to the message. |
| messages[].files[].name | string | The name of the file. |
| messages[].files[].type | string | The MIME type of the file. |
| messages[].files[].url | string | The URL of the file in Azure Blob Storage. Use the `/get-sas-token` endpoint to get a URL with a SAS token for public access. |
| messages[].audioUrl | string | The URL of the audio file in Azure Blob Storage, when the message is a transcript of an audio input. Use the `/get-sas-token` endpoint to get a URL with a SAS token for public access. |
| messages[].score | number | The level of confidence in the generated answer. If no score is present, the answer was not derived from search results. |
| answer | object | The assistant message produced by this turn. |
| source | string | Origin of the request (for example `widget`, `iframe`, `site` or `api`). |
| cost_credits | number | Credits charged for this turn. |
| regenerate | boolean | Present and `true` only when the `/chat` request used `skipCache: true` (a regenerated answer); absent otherwise. To tell regenerated events apart, check `payload.regenerate === true`. |
| client_ip | string | Caller IP, included only when the chatbot has `trackIpAddress` enabled. |
| is_live_chat | boolean | Whether the turn was handled by a live human agent. |
| conversation_url | string | URL to view the conversation. Only present when the conversation portal is enabled. |

## Regenerated answers

A `/chat` request sent with `skipCache: true` regenerates the answer, and the `chat` event it triggers carries `regenerate: true`. The field is absent (not `false`) on normal sends, so receivers can dedupe with `payload.regenerate === true`.
