---
title: "Webhooks"
description: "Send Chat Thing events such as new conversations, escalations and syncs to your own systems, Slack or Discord, and trigger data source syncs from a URL."
canonical_url: "https://chatthing.ai/docs/developers/webhooks"
last_updated: "2026-09-25"
---

# Webhooks

Webhooks connect a bot to the rest of your tools. Chat Thing can call your URL when something happens, such as a new conversation or a customer asking for a person, and your systems can call a Chat Thing URL to re-sync a data source, for example when you publish an article in your CMS.

Webhooks are set up per bot, on the bot's **Webhooks** tab. You need to be an [owner or admin](https://chatthing.ai/docs/account/teams#what-each-role-can-do) of the bot's team.

## Webhook types

| Webhook | Direction | Fires when |
| --- | --- | --- |
| [Sync trigger](https://chatthing.ai/docs/developers/webhooks#sync-trigger-webhook) | Incoming: your system calls Chat Thing | Your system calls the webhook's URL. It starts a sync on one data source |
| [Sync success](https://chatthing.ai/docs/developers/webhooks#sync-success-webhook) | Outgoing: Chat Thing calls your URL | A data source finishes syncing |
| [Sync failure](https://chatthing.ai/docs/developers/webhooks#sync-failure-webhook) | Outgoing | A data source fails to sync |
| [Conversation started](https://chatthing.ai/docs/developers/webhooks#conversation-started-webhook) | Outgoing | A new conversation starts with the bot |
| [Conversation escalated](https://chatthing.ai/docs/developers/webhooks#conversation-escalated-webhook) | Outgoing | A user asks to speak to a person through the [Talk to a human](https://chatthing.ai/docs/power-ups/talk-to-a-human) power-up |
| [Conversation claimed](https://chatthing.ai/docs/developers/webhooks#conversation-claimed-webhook) | Outgoing | A team member takes over a conversation |
| [Conversation released](https://chatthing.ai/docs/developers/webhooks#conversation-released-webhook) | Outgoing | A team member hands a conversation back to the bot |

**Incoming** webhooks only need a secret, which is part of the URL you give to the other system. **Outgoing** webhooks need the URL to send events to, plus a secret you can use to check the request really came from Chat Thing.

## Add a webhook

1. Open your bot and go to the **Webhooks** tab.
2. Click **New webhook**.
3. Choose the webhook type. For a **Sync trigger webhook**, also choose the **Data source** it should sync.
4. Click **Create webhook**. The webhook's settings page opens. New webhooks start turned off.
5. Fill in the settings (see [Outgoing webhook settings](https://chatthing.ai/docs/developers/webhooks#outgoing-webhook-settings) or [Sync trigger webhook](https://chatthing.ai/docs/developers/webhooks#sync-trigger-webhook)) and click **Save**.
6. Test it (see [Test a webhook](https://chatthing.ai/docs/developers/webhooks#test-a-webhook)).
7. Turn on the toggle at the top of the settings page. It changes to **Webhook is enabled**.

![Webhooks tab with no webhooks yet and a New webhook button](https://res.cloudinary.com/djyjvrw5u/image/upload/v1777304188/docs/webhooks-empty-state.png)

![New webhook page listing each webhook type as a selectable card](https://res.cloudinary.com/djyjvrw5u/image/upload/v1777304189/docs/webhooks-catalog.png)

![Choosing the Sync trigger webhook opens a Choose a data source window listing the bot's data sources](https://res.cloudinary.com/djyjvrw5u/image/upload/v1777304191/docs/webhooks-catalog-sync-trigger-selected.png)

## Manage webhooks

- **Turn a webhook on or off** with the toggle on its card or at the top of its settings page. A turned-off outgoing webhook sends no events (though you can still test it). A turned-off Sync trigger webhook rejects calls to its URL.
- **Edit a webhook**: on its card, open the menu (**⋮**) and choose **Settings**.
- **Delete a webhook**: open the menu (**⋮**), choose **Delete** and confirm. This can't be undone, and a Sync trigger webhook's URL stops working.

![Webhook card with its menu open, showing Settings and Delete](https://res.cloudinary.com/djyjvrw5u/image/upload/v1777304194/docs/webhooks-grid-kebab-menu.png)

![The confirmation window asking "Are you sure you want to delete this webhook?" with Cancel and Delete buttons](https://res.cloudinary.com/djyjvrw5u/image/upload/v1777304196/docs/webhooks-delete-modal.png)

## Test a webhook

- **Outgoing webhooks**: click **Test hook** at the bottom of the settings page. Chat Thing sends a sample event to your saved URL, using your saved headers and body, and shows the response status and body at the top of the page. It uses the saved settings, so click **Save** first. Testing works even while the webhook is turned off, and test requests carry the header `X-ChatThing-Test: true`.
- **Sync trigger webhooks**: click **Trigger webhook**. It opens the webhook URL in a new tab, which starts a sync.

![Test result panel showing a 200 OK response at the top of the webhook settings page](https://res.cloudinary.com/djyjvrw5u/image/upload/v1777304198/docs/webhooks-test-hook-result.png)

## Outgoing webhook settings

### Delivery

- **Webhook target URL**: the full URL to send events to, including `https://`. Chat Thing sends a `POST` unless you change the method in a custom payload.
- **Data source** (Sync success and Sync failure only): leave empty to fire for every data source on the bot, or choose one.

### Authentication

Every outgoing webhook has a **Hook secret**, used to sign each request (see [Verify requests](https://chatthing.ai/docs/developers/webhooks#verify-requests)). Click the regenerate icon to create a new one. Once you save a new secret, requests are signed with it, so update your receiver at the same time.

### Use a preset

To post events straight into a chat app, click **Slack** or **Discord** under **Use a preset**, then paste that app's incoming webhook URL as the **Webhook target URL**. The preset fills in a ready-made message you can edit. Click **Clear preset** to go back.

### Custom payload

By default, each webhook sends a fixed JSON body (shown for each type below) with a `POST`. Turn on **Custom payload** to change it:

- **HTTP method**: `POST`, `PUT`, `PATCH`, `GET` or `DELETE`.
- **Request headers**: a JSON object of headers.
- **Request body**: the exact text to send.

You can put variables in the target URL, headers and body using `{{path}}` placeholders. They're filled in from the event when it's sent. For example, on a Conversation started webhook:

```json
{ "bot": "{{conversation.botName}}", "message": "{{conversation.initialMessage}}" }
```

Useful details:

- Most variables also have a short name, such as `{{botName}}`, `{{conversationId}}`, `{{initialMessage}}`, `{{userData}}` or `{{agentName}}`. The variables sidebar shows both.
- Add `| json` to insert a value safely inside a JSON string, for example `"text": "New chat: {{initialMessage | json}}"`. It escapes quotes and line breaks, and turns objects into text.
- The target URL and headers can only use single values (text, numbers or true/false), such as `{{conversation.id}}`, not whole objects such as `{{conversation}}`. The body can use both: objects and arrays are sent as JSON.
- If a placeholder doesn't match anything (for example a typo like `{{conversation.userData.emial}}`), it's sent as-is. **Test hook** warns you about these before you go live.

![Custom payload section with header and body editors and the variables sidebar](https://res.cloudinary.com/djyjvrw5u/image/upload/v1777304201/docs/webhooks-custom-payload-section.png)

### Variables sidebar

The sidebar lists every variable for the webhook type. Click a variable to insert it into the field you last clicked. While you're editing the URL or headers, it only shows single values. While you're editing the body, it shows everything, and marks objects with an **object** badge. Open **Variable reference** at the bottom for a description of each one.

## Sync trigger webhook

Call this webhook's URL from another system to re-sync a data source, for example from your CMS when you publish a page.

1. Choose the **Data source** to sync.
2. Save, turn the webhook on, and copy the URL from the sidebar. It looks like this:

```text
https://app.chatthing.ai/api/public/hooks/{hookId}/{secret}
```

The secret is part of the URL, so there's no separate header. Regenerating the secret changes the URL and the old one stops working.

> **Treat the URL like a password**
>
> Anyone with the URL can start a sync on your data source. If it leaks, regenerate the secret.

Call it with any HTTP method:

**curl**

```bash
curl --request POST \
  --url https://app.chatthing.ai/api/public/hooks/{hookId}/{secret}
```

**JavaScript**

```javascript
const response = await fetch(
  "https://app.chatthing.ai/api/public/hooks/{hookId}/{secret}",
  { method: "POST" },
);
console.log(await response.json());
```

**Python**

```python
import requests

response = requests.post("https://app.chatthing.ai/api/public/hooks/{hookId}/{secret}")
print(response.json())
```

A successful call returns `{ "success": true }` once the sync has started. If a sync is already running, or your team is out of storage tokens, you get `429` with `"success": false` and a `message` explaining why. Each sync uses [storage tokens](https://chatthing.ai/docs/account/storage-tokens).

## Event payloads

Every outgoing event body includes a `deliveryId`: the same id as the `X-ChatThing-Delivery-Id` header. It stays the same across retries, so you can use it to ignore duplicates.

The conversation events also include `conversation.conversationLink`, a link that opens the conversation in Chat Thing.

### Sync success webhook

Fires when a data source on the bot finishes syncing. If you chose a data source, it only fires for that one.

| Field | Type | Description |
| --- | --- | --- |
| `success` | boolean | Always `true` |
| `results.totalTokens` | number | Storage tokens used by this sync |
| `results.modifiedRows` | number | Rows that changed since the last sync |
| `results.unmodifiedRows` | number | Rows that didn't change |
| `results.totalDataSourceRows` | number | Rows in the data source |
| `results.totalDocuments` | number | Documents created from the data source |
| `bot` | string | The bot's name |
| `dataSource` | string | The data source's name |

```json
{
  "success": true,
  "deliveryId": "d3c5a9e8-1b2c-4f5a-9b8d-1a2b3c4d5e6f",
  "results": {
    "totalTokens": 241245,
    "modifiedRows": 15,
    "totalDocuments": 20,
    "unmodifiedRows": 0,
    "totalDataSourceRows": 15
  },
  "bot": "Support bot",
  "dataSource": "Help centre"
}
```

### Sync failure webhook

Fires when a data source on the bot fails to sync. If you chose a data source, it only fires for that one.

| Field | Type | Description |
| --- | --- | --- |
| `success` | boolean | Always `false` |
| `reason` | string | Why the sync failed |
| `bot` | string | The bot's name |
| `dataSource` | string | The data source's name |

```json
{
  "success": false,
  "deliveryId": "d3c5a9e8-1b2c-4f5a-9b8d-1a2b3c4d5e6f",
  "reason": "Over plan storage token limit, please upgrade plan",
  "bot": "Support bot",
  "dataSource": "Help centre"
}
```

### Conversation started webhook

Fires when a new conversation starts with the bot, on any channel.

| Field | Type | Description |
| --- | --- | --- |
| `event` | string | `conversation.started` |
| `timestamp` | string | When the event was sent (ISO 8601) |
| `conversation.id` | string | The conversation's id |
| `conversation.botId` | string | The bot's id |
| `conversation.botName` | string | The bot's name |
| `conversation.channelType` | string | The channel, such as `web` or `slack` |
| `conversation.conversationLink` | string | Link to the conversation in Chat Thing |
| `conversation.createdAt` | string | When the conversation started (ISO 8601) |
| `conversation.initialMessage` | string | The user's first message, when there is one |
| `conversation.userData` | object | Anything known about the user, such as details from the [pre-chat form](https://chatthing.ai/docs/channels/website/lead-form) or [`identifyUser`](https://chatthing.ai/docs/developers/javascript-sdk#identify-the-user). `{}` when nothing is known |

```json
{
  "event": "conversation.started",
  "deliveryId": "d3c5a9e8-1b2c-4f5a-9b8d-1a2b3c4d5e6f",
  "timestamp": "2026-03-10T12:00:00.000Z",
  "conversation": {
    "id": "abc-123",
    "botId": "def-456",
    "botName": "Support bot",
    "channelType": "web",
    "conversationLink": "https://app.chatthing.ai/app/conversations/bot/def-456?conversation=abc-123",
    "createdAt": "2026-03-10T12:00:00.000Z",
    "initialMessage": "What are your opening hours?",
    "userData": { "name": "Alex Doe", "email": "alex@example.com" }
  }
}
```

### Conversation escalated webhook

Fires when a user asks to speak to a person through the [Talk to a human](https://chatthing.ai/docs/power-ups/talk-to-a-human) power-up, on any channel, whether or not you use [human takeover](https://chatthing.ai/docs/manage/human-takeover).

| Field | Type | Description |
| --- | --- | --- |
| `event` | string | `conversation.escalated` |
| `timestamp` | string | When the event was sent (ISO 8601) |
| `conversation.id`, `botId`, `botName`, `channelType`, `conversationLink` |  | As for Conversation started |
| `conversation.state` | string | `Escalated` |
| `conversation.userData` | object | Anything known about the user. `{}` when nothing is known |
| `userEmail` | string | The email address the user gave |

```json
{
  "event": "conversation.escalated",
  "deliveryId": "d3c5a9e8-1b2c-4f5a-9b8d-1a2b3c4d5e6f",
  "timestamp": "2026-03-10T12:05:00.000Z",
  "conversation": {
    "id": "abc-123",
    "botId": "def-456",
    "botName": "Support bot",
    "channelType": "web",
    "conversationLink": "https://app.chatthing.ai/app/conversations/bot/def-456?conversation=abc-123",
    "state": "Escalated",
    "userData": {}
  },
  "userEmail": "user@example.com"
}
```

### Conversation claimed webhook

Fires when a team member takes over a conversation. See [Human takeover](https://chatthing.ai/docs/manage/human-takeover).

| Field | Type | Description |
| --- | --- | --- |
| `event` | string | `conversation.claimed` |
| `timestamp` | string | When the event was sent (ISO 8601) |
| `conversation.id`, `botId`, `botName`, `channelType`, `conversationLink` |  | As for Conversation started |
| `conversation.state` | string | `Active` |
| `agent.name` | string | The team member's name |
| `agent.email` | string | The team member's email address |

```json
{
  "event": "conversation.claimed",
  "deliveryId": "d3c5a9e8-1b2c-4f5a-9b8d-1a2b3c4d5e6f",
  "timestamp": "2026-03-10T12:10:00.000Z",
  "conversation": {
    "id": "abc-123",
    "botId": "def-456",
    "botName": "Support bot",
    "channelType": "web",
    "conversationLink": "https://app.chatthing.ai/app/conversations/bot/def-456?conversation=abc-123",
    "state": "Active"
  },
  "agent": {
    "name": "Sam",
    "email": "sam@example.com"
  }
}
```

### Conversation released webhook

Fires when a team member hands a conversation back to the bot.

| Field | Type | Description |
| --- | --- | --- |
| `event` | string | `conversation.released` |
| `timestamp` | string | When the event was sent (ISO 8601) |
| `conversation.id`, `botId`, `botName`, `channelType`, `conversationLink` |  | As for Conversation started |
| `conversation.state` | string | `Resolved` |
| `agent.name` | string | The team member's name |
| `agent.email` | string | The team member's email address |

```json
{
  "event": "conversation.released",
  "deliveryId": "d3c5a9e8-1b2c-4f5a-9b8d-1a2b3c4d5e6f",
  "timestamp": "2026-03-10T12:15:00.000Z",
  "conversation": {
    "id": "abc-123",
    "botId": "def-456",
    "botName": "Support bot",
    "channelType": "web",
    "conversationLink": "https://app.chatthing.ai/app/conversations/bot/def-456?conversation=abc-123",
    "state": "Resolved"
  },
  "agent": {
    "name": "Sam",
    "email": "sam@example.com"
  }
}
```

## Verify requests

Every outgoing request is signed with your hook secret. Checking the signature proves the request came from Chat Thing and wasn't changed on the way. If you're only trying things out (for example with [webhook.site](https://webhook.site)), you can skip this.

### Request headers

| Header | Description |
| --- | --- |
| `X-ChatThing-Signature` | `sha256=<hex>`: an HMAC-SHA256 of the exact request body, using your hook secret as the key |
| `X-ChatThing-Delivery-Id` | An id that stays the same across retries of one delivery. Use it to ignore duplicates |
| `X-ChatThing-Event` | The event name: `sync.success`, `sync.failure`, `conversation.started`, `conversation.escalated`, `conversation.claimed` or `conversation.released` |
| `X-ChatThing-Test` | `true` when the request was sent with **Test hook**. Not sent for real events |
| `X-Secret-Key` | Your hook secret, kept for older integrations. Prefer checking the signature |

### Check the signature

Calculate an HMAC-SHA256 of the raw request body with your hook secret, hex-encode it, add `sha256=` in front, and compare it with `X-ChatThing-Signature` using a constant-time comparison.

**Node.js**

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

function verifyChatThingSignature(rawBody, headerValue, secret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(headerValue ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
import hmac
import hashlib

def verify_chatthing_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header_value or "")
```

Use the **raw** body exactly as received. Parsing it as JSON and turning it back into text changes the bytes, and the signature won't match.

## Retries and timeouts

- If your receiver fails or is unreachable, Chat Thing tries again, up to 5 attempts in total, waiting longer each time (about 30 seconds, then 1, 2 and 4 minutes).
- Retries keep the same `X-ChatThing-Delivery-Id`, so receivers that check it won't process an event twice.
- Each request times out after 10 seconds.
- Redirects (3xx responses) aren't followed. Use the final URL.
- URLs that point to private or internal network addresses are blocked.

## Troubleshooting

### My webhook isn't firing

Check the webhook is turned on: the top of its settings page should say **Webhook is enabled**. Turned-off outgoing webhooks don't send real events, though **Test hook** still works. For Sync success and Sync failure, check the **Data source** setting isn't limited to a different source.

### My receiver gets `{{conversation.id}}` as text

The placeholder didn't match anything, usually because of a typo. Insert variables from the sidebar instead of typing them, and check the **Test hook** result for an unresolved placeholders warning.

### My signature check always fails

Make sure you're using the raw request body, not a parsed and re-serialised copy, and the current **Hook secret**. If you regenerated the secret, update your receiver.

### I just want to see what a webhook sends

[webhook.site](https://webhook.site) gives you a free, temporary URL that shows every request it receives. Use it as the **Webhook target URL**, click **Test hook**, and you'll see exactly what your server would get.

Need an event that isn't listed here? Email [support@chatthing.ai](mailto:support@chatthing.ai).

## Related

- [Take over a live chat from your bot](https://chatthing.ai/docs/manage/human-takeover): Let your team step into a live website chat, reply to the customer directly while the bot pauses, then hand the conversation back to the bot.
- [Hand off a chat to a human](https://chatthing.ai/docs/power-ups/talk-to-a-human): Let your bot email your team with the visitor's email and the conversation when someone asks for a person, and mark the chat as escalated.
- [Keep your bot's knowledge up to date](https://chatthing.ai/docs/knowledge/keep-it-up-to-date): Re-sync data sources by hand, on a daily, weekly or monthly schedule, or automatically when you publish in your CMS, and see what each sync changes.
