
# Webhooks

Webhooks let <span data-t="appName">DM Champ</span> automatically notify your other business tools whenever something important happens — a new contact being created, an appointment being booked, a message being received. Instead of manually checking for updates, your connected systems get an instant notification the moment something happens.

::: walkthrough webhooks
:::

---

## What Are Webhooks?

Think of a webhook like an automatic text message between two apps. When something happens in <span data-t="appName">DM Champ</span> (like a new contact signing up), the platform instantly sends a notification to another system of your choice. You provide a web address (called a "webhook URL") where these notifications should be sent — this is typically provided by your CRM, automation platform, or developer.

> **Webhooks only send data OUT of <span data-t="appName">DM Champ</span>.** A webhook is a one-way street *from* <span data-t="appName">DM Champ</span> *to* your other tools. There is **no webhook URL that sends leads, contacts, or messages INTO the platform.** To push a new lead in — from a website form, your CRM, or GoHighLevel — your system makes an **API call** instead. See [API Access](api-access.md) (the *Create a Contact* operation) and [Funnels](funnels.md). The only thing you need for the inbound direction is your **API key**, which lives in its own section — see [API Access](api-access.md#generating-your-api-key). The **Webhooks** page described here is exclusively for the outbound direction.

::: note
**Note:** Setting up webhooks involves some technical configuration. If you're not comfortable with this, share this page with your developer or use an automation platform like Zapier, Make, or Pabbly, which provide webhook URLs with no coding required.
:::


Common uses include:

- Syncing new contacts to your CRM.
- Triggering a workflow in Zapier, Make, or Pabbly when a tag is applied.
- Notifying your team in Slack when a human is alerted.
- Updating your calendar system when an appointment is booked.
- Logging conversation summaries to your database.

---

## Setting Up Webhooks

1. In the left sidebar, click **Settings** (gear icon).
2. In the Settings sidebar, under the **Integrations** group, click **Webhooks**.

::: master-only
<figure><img src="../.gitbook/assets/v2-settings-overview.png" alt="The Settings page, showing the grouped left-rail navigation"><figcaption><p>Webhooks and API Key are two separate sections under Integrations in v2 — the API key is no longer bundled onto this page.</p></figcaption></figure>
:::

On an account with no webhooks configured yet, the page looks like this:

::: master-only
<figure><img src="../.gitbook/assets/v2-settings-webhooks.png" alt="The Webhooks page with no webhooks yet, showing the empty-state card and the New webhook button"><figcaption><p>Nothing to see yet — click <strong>New webhook</strong> (top-right, or the button in the empty-state card) to add your first one. Once you have at least one saved, each row shows its own <strong>Test</strong> button and an on/off switch, and clicking the row opens its <strong>Signing secret</strong> panel — none of that is available until a webhook is actually saved.</p></figcaption></figure>
:::

3. Click **New webhook**, top-right. A form opens inline on the page:

::: master-only
<figure><img src="../.gitbook/assets/v2-webhook-new-form.png" alt="The New webhook form: Endpoint URL and Name fields, event chips to pick from, the Retry failed deliveries toggle, the agency-only Also fire for all client accounts toggle, and the signing secret note"><figcaption><p>The New webhook form. Type your endpoint URL, pick at least one event chip, and click <strong>Create webhook</strong>. The signing secret becomes available after the webhook is saved. The <strong>Also fire for all client accounts</strong> toggle only appears on agency accounts — see <a href="#one-webhook-for-all-your-client-accounts-agencies">One Webhook for All Your Client Accounts</a>.</p></figcaption></figure>
:::

4. Fill in:
   - **Endpoint URL** — the web address <span data-t="appName">DM Champ</span> will send event notifications to. You get this from your external system (CRM, automation platform, or custom server).
   - **Name** — a label you'll recognize later (e.g. "Slack alerts" or "CRM sync"). For your reference only.

> **Your webhook URL must be a publicly reachable `https://` address.** Plain `http://` addresses, `localhost` or private-network addresses, and platform-internal addresses are rejected when you save. To test from your own machine, use a public tunnel (webhook.site or ngrok) instead of localhost.

5. Under **Events**, click the events you want this webhook to receive — all 22 are listed in [The 22 Webhook Events](#the-22-webhook-events).
6. *(Optional)* Turn on **Retry failed deliveries** if you want <span data-t="appName">DM Champ</span> to keep trying on a temporary failure — see [Retrying Failed Deliveries](#retrying-failed-deliveries).
7. Click **Create webhook**. It appears in the list below the form, and you can click **Test** on its row any time to fire a sample payload at your endpoint.

> **Permission needed.** Adding, editing, or testing webhooks requires the Integrations "edit" permission (view-only team members see a read-only notice instead of the form).

> **Signing a webhook** requires it to already be saved first — open an existing webhook's row to edit it, and the **Signing secret** panel appears at the bottom of the edit form. A brand-new, unsaved draft has no signing option yet — see [Signed Payloads](#signed-payloads-verifying-a-webhook-really-came-from-us) below.

---

## One Webhook for All Your Client Accounts (Agencies)

If you run an agency, you don't have to re-create the same webhook on every client account. On the agency account, the webhook form has an extra toggle: **Also fire for all client accounts**. Turn it on and this webhook also receives events that happen on every client account under your agency — one endpoint, whole agency.

How it behaves:

- **The `user` block tells you which client an event belongs to.** Every notification already carries a `user` block identifying the account the event happened on, so your automation can route per client.
- **Your webhook's own settings apply everywhere.** The events you selected, the signing secret, and the retry setting are used for client-account deliveries too.
- **No double deliveries.** If a client account has its own webhook pointing at the same URL, that one is used for that account's events instead — the same event never arrives twice at one endpoint.
- **Clients don't see it.** The webhook does not appear on the client account's own Webhooks page, and clients can't switch it off — it's yours to manage.
- **Reliability is tracked per client account.** If your endpoint keeps failing, it is switched off automatically for the account whose deliveries failed (see [Webhook Reliability](#webhook-reliability)), not for the whole agency at once.

The toggle only appears on agency accounts. Setting it over the API is also supported — see the `apply_to_sub_accounts` field in the [Webhooks API](../api/webhooks.md#one-subscription-for-all-client-accounts-agencies).

---

## Available Trigger Events

You can enable or disable each of the 22 webhook events independently. When an event fires, <span data-t="appName">DM Champ</span> sends a notification to your webhook URL with the relevant data. Every event, what it means, and the `event` code it puts in the payload are listed together in [The 22 Webhook Events](#the-22-webhook-events) further down this page.

> **Good to know:** **Task Created**, **Task Updated**, and **Task Completed** are fully selectable and save correctly. **Daily Summary Created** is also a recent addition. See [Task Completed Webhook](#task-completed-webhook) below for that payload shape.

---

## Tag-Based Webhook Triggers

`subscribed_to_tags` does not scope a webhook's events to a tag. It only narrows which tags produce a conversation-summary notification. To get a request when a specific tag is applied, set a webhook URL on that tag in the **Tags** tab of the agent (or campaign).

The webhook form itself has no tag picker, either when creating a new webhook or when editing one, so `subscribed_to_tags` can only be read or changed through the [Webhooks API](../api/webhooks.md), or by asking support.

> **Good to know:** editing an existing webhook that has a `subscribed_to_tags` list (renaming it, changing its events, toggling retries) no longer clears that list — since the form has no tag picker to send back, saving from this page now leaves the existing list untouched. (This was a real bug before **July 21, 2026**: saving from the webhook form used to erase the list because it always sent an empty tag list. If a webhook lost its `subscribed_to_tags` list before that date, it will need to be reconfigured through the API.)

### Generate Summary for Tagged Contacts

Where a webhook has a `subscribed_to_tags` list, you can turn on **Generate Summary**. When enabled, <span data-t="appName">DM Champ</span> automatically generates a conversation summary for the contact when one of those tags is applied, and includes it in the webhook data — full context without a separate request.

---

## Testing Your Webhook

1. Open **Settings → Integrations → Webhooks**.
2. On your webhook's row, click **Test**.
3. Check your external system to confirm it received the test data.
4. Review the data format to make sure your system can parse it correctly.

For a full end-to-end test, send a message that would trigger one of your configured events (a broadcast, or an incoming message on a connected channel) and verify the webhook fires with the real data.

::: tip
**Tip:** Use a tool like [webhook.site](https://webhook.site) or [RequestBin](https://requestbin.com) during development to inspect the raw webhook data before connecting your production system.
:::


### What Counts as a Successful Delivery

Whether you click **Test** or the event fires for real, we send the same thing:

- A **POST** request (never GET), with the body as JSON and `Content-Type: application/json`.
- The headers listed under [Signed Payloads](#signed-payloads-verifying-a-webhook-really-came-from-us). Signature headers are only included once you've set a signing secret.

We treat the delivery as successful when:

- Your endpoint answers with **any 2xx status** (200, 201, 204 — all fine).
- It answers **within 30 seconds**.

A few things that surprise people:

- **The response body is ignored.** You don't need to return any particular JSON. An empty 200 is enough.
- **Redirects count as a failure.** We don't follow them, so a 301 or 302 (including a trailing-slash redirect, or http to https) is recorded as a failed delivery. Save the final URL, not one that redirects.
- **Query strings are fully supported.** `https://your-app.com/hook?token=abc123` is sent exactly as you saved it, so putting a token in the query string works just as well as putting it in the path.
- **Your URL must be `https://` and publicly reachable.** Addresses that belong to <span data-t="appName">DM Champ</span>'s own infrastructure are rejected, but your own endpoints on Google Cloud Functions, Cloud Run, App Engine, Firebase Hosting or anywhere else are fine.
- **A firewall or bot-protection layer in front of your endpoint can block us.** The most common case is Cloudflare: if your zone has Bot Fight Mode or a managed challenge on, our request gets a "Just a moment..." challenge page with a 403 instead of reaching your server — and a server-to-server request can never pass a browser challenge, so both the **Test** button and real events fail the same way. The Test button will tell you when this is happening ("Cloudflare is showing a bot challenge to our request"). Fix it in Cloudflare with a Security / WAF rule that skips challenges for your webhook path (or for the `Webhook-Delivery/1.0` user agent), then click **Test** again.
- **If your firewall needs an IP allowlist instead** (for example Cloudflare's free plan, where plain Bot Fight Mode can't be skipped by a WAF rule, but an IP Access Rule set to Allow runs before it), we can help: every delivery, whether from the **Test** button or a live event, is sent from one fixed IPv4 address (no ranges, no IPv6, no rotation). Contact support and we will give you the address to allowlist. Keep [signature verification](#signed-payloads-verifying-a-webhook-really-came-from-us) as your actual trust check, since it validates every payload regardless of where it came from.
- **The Test result tells you exactly what your endpoint answered.** A failed test now shows the real reason (the HTTP status your endpoint returned, a timeout, or that we couldn't reach the address at all) instead of a generic error, and a test on a saved webhook is sent signed when signing is on, exactly like a live event.

### Using n8n, Make, or Zapier ("Test URL" vs "Production URL")

Automation platforms usually give you two different webhook addresses, and this trips people up:

- A **Test URL** (in n8n it contains `/webhook-test/`). This only receives data while you're actively watching the canvas and have just clicked **Listen for test event** (or **Test workflow**). It captures a single event and then stops listening — so clicking **Test** in <span data-t="appName">DM Champ</span> several times in a row only catches the first one, and only if the listen window is active at that exact moment. To test: click **Listen for test event** in n8n first, then come back to <span data-t="appName">DM Champ</span> and click **Test** once.
- A **Production URL** (in n8n it contains `/webhook/`, no `-test`). This is the one to paste into <span data-t="appName">DM Champ</span> for live events. It only works once your workflow is switched **Active**. If the workflow isn't active, n8n rejects the request with a "404 / webhook not registered" error, even though <span data-t="appName">DM Champ</span> sent the data correctly.

In short: test with the Test URL while listening, but for the webhook to keep working on real contacts, save the **Production URL** in <span data-t="appName">DM Champ</span> and make sure the workflow is **Active**.

---

## Webhook Data Format

When a webhook fires, <span data-t="appName">DM Champ</span> sends structured data (JSON) to your webhook URL. If you're using an automation platform like Zapier or Make, it parses this data for you automatically. If you're building a custom integration:

```json
{
  "event": "contactCreated",
  "contact": { "id": "<contact-id>", "first_name": "Jane", "...": "..." },
  "campaign": { "id": "<campaign-id>", "name": "AI Receptionist", "status": "Live" },
  "agent": { "id": "<agent-id>", "name": "Front Desk" },
  "user": { "id": "<account-id>", "email": "owner@example.com" }
}
```

| Field | Description |
|---|---|
| `event` | The exact event string that triggered the notification (for example, `contactCreated`, `booked`). This is **not** the display label shown in the events list; every label and its matching code is in [The 22 Webhook Events](#the-22-webhook-events). |
| `contact` | The contact the event is about, or `null` for events not tied to a contact (such as `creditsRecharged`). |
| `campaign` | The campaign the contact belongs to, or `null` if there isn't one. |
| `agent` | The agent handling the conversation, or `null` if there isn't one. |
| `user` | Basic identity information for the account that owns the data. |

> **`campaign` or `agent` — usually one, not both.** If your account uses agents, your contacts sit with an agent rather than a campaign, so `campaign` arrives as `null` and `agent` tells you which one handled it. Older campaign-based accounts see the reverse. Read whichever one is filled in; don't assume `campaign` is always there.

> **The `agent` block arrived on 15 August 2026.** It sits alongside `campaign` on the events tied to a conversation — a concluded chat, do-not-disturb, a resume, an unarchive, an AI pause, a new message, a conversation summary, and the webhook you can set on a tag — and carries the handling agent's `id` and `name`, or `null` when no agent is involved. It is purely additive: every field you already receive is unchanged, so a receiver you built before that date keeps working with nothing to update.

Some events add their own extra top-level block. For example, **Appointment Booked** adds an `appointment` block (see [Appointment Booked Webhook](#appointment-booked-webhook)), **New Message** adds a full `message` block with the text (see [New Message Webhook](#new-message-webhook)), and **Deliveries** and **Reads** add a short `message` block with just the message's ID and status (see [Deliveries and Reads Webhook](#deliveries-and-reads-webhook)).

> **Deliveries and Reads tell you which message, but not what it said.** They carry a `message` block containing the message's `id` and `status` — and that `id` is the same `messageId` the [send message endpoint](../api/messages.md#send-a-message) hands back, so you can match a delivery or read receipt to the exact message you sent — but no message body. **Replies** carries no `message` block at all. If you need the words that were sent or received, subscribe to **New Message** alongside them.

> **Two things to know before you write your receiver.** There's no `timestamp` field, and no `data` wrapper. Every block sits at the top level of the JSON object, as shown above.

### The 22 Webhook Events

The 22 webhook events, with the display label you tick in the app and the `event` code sent in the payload. The `event` code is a short string that does **not** match the display label, so match your receiver on the code, not the label:

| Display label (in the app) | `event` code in the payload | What it means |
|---|---|---|
| Contact Created | `contactCreated` | A new contact is added to your account (manually, via import, or via API). |
| Contact Paused | `contact_paused` | A contact conversation is paused (bot stops responding). |
| Contact Resumed | `contact_resumed` | A paused contact conversation is resumed. |
| Contact Do Not Disturb | `contact_do_not_disturb_changed` | A contact's Do Not Disturb setting is turned on. |
| Contact Unarchived | `contact_unarchived` | An archived contact sends a new message, bringing them back into your active inbox. |
| New Message | `new_message` | Any message is added to a conversation on any channel — both messages your contact sends you and messages your AI or your team sends them. This is the only event that carries the actual message text (see [New Message Webhook](#new-message-webhook)). |
| Replies | `replied` | A contact replies to a message. |
| Reads | `read` | A contact reads a message (on channels that support read receipts). Carries the ID of the message that was read — see [Deliveries and Reads Webhook](#deliveries-and-reads-webhook). |
| Deliveries | `delivered` or `undelivered` | A message is successfully delivered to a contact (`undelivered` when delivery fails). Carries the ID of the message — see [Deliveries and Reads Webhook](#deliveries-and-reads-webhook). |
| Human Alerted | `humanAlerted` | The AI bot determines it cannot handle a conversation and flags it for human attention. |
| Chat Concluded | `chat_concluded` | The AI bot decides a conversation has reached its end (booking made, lead disqualified, etc.). |
| Appointment Booked | `booked` | A contact books an appointment through the booking system. |
| Credits Spent | `creditsSpent` | Credits are deducted from your account. |
| Credits Recharged | `creditsRecharged` | Credits are added to your account via auto-recharge or manual purchase. |
| Low Credit Balance | `lowCreditBalance` on a **Test** delivery, `Low Credit Balance` on a real one | An early warning that your credit balance has dropped below your alert threshold (100 credits unless you set your own). Aimed at agencies, whose sub-accounts all spend from one pool. It carries `balance`, `threshold` and `account_email` instead of a contact block, is sent at most once every 24 hours while the balance stays low, and re-arms as soon as the balance goes back above the threshold. |
| Task Created | `taskCreated` | A task is created. |
| Task Updated | `taskUpdated` | A task changes without moving into a completion stage. |
| Task Completed | `taskCompleted` | A task moves into a stage configured as a completion stage. |
| Daily Summary Created | `dailySummaryCreated` | Your daily summary report is generated. |
| Channel Connected | `channelConnected` | **Not sent yet — selectable, but nothing emits it today. Don't build against it.** Intended for when a messaging channel finishes connecting. |
| Broadcast Started | `broadcastStarted` | A broadcast begins sending (its status changes to Sending). Fires once per start, including when a paused broadcast is resumed. Carries a `broadcast` block instead of a contact block: id, name, channel, status, previous status, the list it targets (`list_id`, `list_name`, `is_smart_list`), `scheduled_at`, `total_contacts`. |
| Broadcast Completed | `broadcastCompleted` | A broadcast finishes (its status changes to Sent or Failed). Same `broadcast` block plus `completed_at` and, when available, `completion_summary` (`total_sent`, `permanently_failed`, `unique_replied`, `failure_rate`, `had_errors`). Use these two to connect a Smart Broadcast List to external tools. |

Two more codes never appear in that list because you don't subscribe to them: `contact_tags_updated`, sent by a webhook URL set on an individual tag, and `summary_generated`, sent when a chat summary is written for a tag in a webhook's `subscribed_to_tags` list.

> **Channel Connected is not sent yet.** It appears in the events list, but nothing emits it today. Don't build against it.

Tag-based and task notifications use their own separate shapes. See [Contact Tags Updated](#contact-tags-updated-webhook) and [Task Completed](#task-completed-webhook).

---

## Contact Created Webhook

Sent when the **Contact Created** event fires (a new contact is added manually, via import, or via API).

### Event name

`contactCreated`

### Payload format

```json
{
  "event": "contactCreated",
  "contact": {
    "id": "<contact-id>",
    "email": "jane@example.com",
    "phone_number": "+15551234567",
    "first_name": "Jane",
    "last_name": "Smith",
    "human_alerted": false,
    "human_alert_reason": null,
    "is_bot_active": true,
    "ad_referral": null
  },
  "campaign": {
    "id": "<campaign-id>",
    "name": "AI Receptionist",
    "status": "Live"
  },
  "agent": {
    "id": "<agent-id>",
    "name": "Front Desk"
  },
  "user": {
    "id": "<account-id>",
    "email": "owner@example.com",
    "first_name": "Alex",
    "last_name": "Doe"
  }
}
```

| Field | Description |
|---|---|
| `event` | Always `contactCreated` for this event. |
| `contact.id` | The unique ID of the new contact. |
| `contact.email` / `contact.phone_number` | The contact's email and phone, if known (either may be empty depending on the channel). |
| `contact.first_name` / `contact.last_name` | The contact's name, if known. |
| `contact.human_alerted` / `contact.human_alert_reason` | Whether the contact is flagged for human attention, and why. |
| `contact.is_bot_active` | Whether the AI bot is currently active on this contact. |
| `contact.ad_referral` | Meta Click-to-WhatsApp ad attribution, or `null` — see [Click-to-WhatsApp Ad Attribution](click-to-whatsapp-attribution.md). |
| `campaign` | The campaign the contact was created under, or `null`. |
| `agent` | The agent assigned to the contact, or `null`. |
| `user` | Basic identity information for the account that owns the contact. |

> **The "Test" sample and a real event look slightly different.** The test button sends placeholder data (John Doe, a sample campaign). A real Contact Created event carries the actual contact's details, and some fields may be empty depending on the channel.

---

## New Message Webhook

This webhook fires every time a message is added to a conversation, on any channel. It covers both directions: messages your contact sends you, and messages your AI, your team, or a campaign sends them. It is the only webhook that includes the message text, so it is the one to use when you want to mirror conversations into an external system.

### Event name

`new_message`

### Payload format

```json
{
  "event": "new_message",
  "contact": {
    "id": "<contact-id>",
    "email": "jane@example.com",
    "phone_number": "+15551234567",
    "first_name": "Jane",
    "last_name": "Smith",
    "human_alerted": false,
    "human_alert_reason": null,
    "is_bot_active": true,
    "ad_referral": null
  },
  "agent": {
    "id": "<agent-id>",
    "name": "Front Desk"
  },
  "user": {
    "id": "<account-id>",
    "email": "owner@example.com",
    "first_name": "Alex",
    "last_name": "Doe"
  },
  "message": {
    "id": "<message-id>",
    "body": "Hi, are you open on Saturday?",
    "direction": "inbound",
    "status": "received",
    "created_at": "2026-07-30T17:27:06.000Z",
    "channel": "whatsapp_web"
  }
}
```

| Field | Description |
|---|---|
| `event` | Always `new_message` for this event. Note this is the exact string sent — it is not the display label "New Message". |
| `contact` | The contact whose conversation the message belongs to. Same shape as in [Contact Created](#contact-created-webhook). |
| `agent` | The agent handling the conversation (`id` and `name`), or `null` if no agent is involved. |
| `user` | Basic identity information for the account that owns the conversation. |
| `message.id` | The unique ID of the message. |
| `message.body` | The message text. Empty for a message that carries only an attachment (image, voice note, document). |
| `message.direction` | `inbound` for a message from the contact, `outbound` for one sent by your AI or by your team from the inbox, and `outbound-api` for one sent by a campaign, a broadcast, a template send, or the API. |
| `message.status` | Where the message is in its lifecycle: `received` for incoming, and `queued` / `sent` / `delivered` / `read` / `failed` / `undelivered` for outgoing. This is the status at the moment the message was created, so an outgoing message usually arrives here as `queued` or `sent` and reaches `delivered` afterwards — use the **Deliveries** and **Reads** events if you need those later transitions. They carry the same `message.id` as this block, so you can match the transition to this message (see [Deliveries and Reads Webhook](#deliveries-and-reads-webhook)). |
| `message.created_at` | When the message was created, in UTC (ISO 8601). |
| `message.channel` | The channel the message went through, for example `whatsapp`, `whatsapp_web`, `sms`, `instagram`, `messenger`, `telegram`, `email` or `custom`. |

> **There is still no `campaign` block in this payload.** New Message sends `contact`, `agent`, `user` and `message`. The `agent` block was added on **15 August 2026** and tells you which agent handles the conversation; if you need campaign context as well, look the contact up through the API using `contact.id`.

> **Internal AI records do not fire this webhook.** Alongside real messages, the platform keeps its own bookkeeping rows in a conversation (the AI's tool calls and internal turn records). Those are never sent — you only receive messages that were genuinely sent or received.

---

## Deliveries and Reads Webhook

These two events report what happened to a message after it left <span data-t="appName">DM Champ</span>: **Deliveries** fires when a message reaches the contact (or fails to), and **Reads** fires when the contact opens it, on the channels that support read receipts.

Both carry a `message` block with the ID of the message the event is about, so you can match the update to the exact message you sent.

### Event names

`delivered` and `undelivered` for **Deliveries**, `read` for **Reads**.

### Payload format

```json
{
  "event": "delivered",
  "contact": {
    "id": "<contact-id>",
    "email": "jane@example.com",
    "phone_number": "+15551234567",
    "first_name": "Jane",
    "last_name": "Smith",
    "ad_referral": null
  },
  "campaign": {
    "id": "<campaign-id>",
    "name": "AI Receptionist",
    "status": "Live"
  },
  "agent": {
    "id": "<agent-id>",
    "name": "Front Desk"
  },
  "user": {
    "id": "<account-id>",
    "email": "owner@example.com",
    "first_name": "Alex",
    "last_name": "Doe"
  },
  "message": {
    "id": "<message-id>",
    "status": "delivered"
  }
}
```

| Field | Description |
|---|---|
| `event` | `delivered` or `undelivered` for **Deliveries**, `read` for **Reads**. |
| `contact` | The contact the message was sent to. |
| `campaign` | The campaign the contact belongs to, or `null`. |
| `agent` | The agent handling the conversation, or `null`. |
| `user` | Basic identity information for the account that owns the data. |
| `message.id` | The ID of the message this update is about. It is the same value the [send message endpoint](../api/messages.md#send-a-message) returns as `messageId`, and the same `message.id` a [New Message](#new-message-webhook) notification carries. |
| `message.status` | The new status, always the same string as `event` (`delivered`, `undelivered` or `read`). |

> **How to match an update to the message you sent.** Store the `messageId` you get back when you send a message through the API. When a **Deliveries** or **Reads** notification arrives, look up that stored ID against `message.id` in the payload — that's your delivery or read receipt for that exact message.

> **There is no message text here.** The `message` block carries only the ID and the status. Subscribe to [New Message](#new-message-webhook) if you also need the body.

> **The `message` block is only present when we know which message it was.** On the rare update we can't tie back to a stored message, the block is left out entirely rather than sent empty — so check that `message` exists before reading `message.id`.

> **One notification per status change.** A single outgoing message normally produces a `delivered` notification and then, on channels with read receipts, a `read` one. A failed send produces `undelivered` instead.

---

## Appointment Booked Webhook

Fires when a contact books an appointment. It fires the same way whether the AI booked it during a conversation, you booked it by hand, or it came in through the API.

### Event name

`booked`

### Payload format

```json
{
  "event": "booked",
  "contact": {
    "id": "<contact-id>",
    "email": "jane@example.com",
    "phone_number": "+15551234567",
    "first_name": "Jane",
    "last_name": "Smith"
  },
  "campaign": {
    "id": "<campaign-id>",
    "name": "AI Receptionist",
    "status": "Live"
  },
  "user": {
    "id": "<account-id>",
    "email": "owner@example.com"
  },
  "appointment": {
    "appointment_id": "<appointment-id>",
    "start_time": "2026-07-20T15:00:00.000Z",
    "end_time": "2026-07-20T15:30:00.000Z",
    "status": "confirmed",
    "room_name": "Room 1",
    "description": "Discovery call",
    "summary": "30 min intro",
    "google_calendar_event_id": null,
    "event": {
      "id": "<service-id>",
      "event_name": "Intro Call",
      "slot_duration": 30,
      "location": "Zoom",
      "meeting_link": "https://...",
      "event_type": "online"
    }
  }
}
```

| Field | Description |
|---|---|
| `event` | Always `booked` for this event. |
| `contact` | The person who booked. `email` and `phone_number` may be empty depending on the channel. |
| `appointment.appointment_id` | The unique ID of the booking. |
| `appointment.start_time` / `end_time` | Start and end of the booked slot, in UTC (ISO 8601). |
| `appointment.status` | The booking's current status. |
| `appointment.room_name` | The room the booking was placed in, if used. |
| `appointment.description` / `summary` | Free-text details captured with the booking. |
| `appointment.google_calendar_event_id` | Google Calendar's ID for the synced event. It is often `null` in the Appointment Booked webhook, because the calendar event is created at the same moment the notification is sent — re-fetch the appointment by its `appointment_id` a moment later if you need it, and expect a permanent `null` on accounts with no Google Calendar connected. |
| `appointment.event` | The service that was booked: name, slot length, location, meeting link, type. |

> **`google_calendar_event_id` is often `null` in this webhook, and that is normal.** The Google Calendar event is created at the same moment this notification goes out, so the ID usually isn't ready yet. Re-fetch the appointment by its `appointment_id` a moment later if you need it. It stays `null` permanently if the account has no Google Calendar connected, so don't wait on it forever.

> **The "Test" button does not include the `appointment` block.** Use it to confirm your endpoint answers, then make one real booking to see the full payload.

> **Two cases where this webhook does not fire:** appointments imported from an external calendar, and bookings that come in through the Formitable integration.

---

## Contact Tags Updated Webhook

Fires when a tag is **applied** to a contact, and that tag has a webhook URL configured on the agent or campaign the contact belongs to.

### Event name

`contact_tags_updated`

### When it fires

- A tag is applied to a contact that has an agent assigned, a campaign assigned, or both.
- At least one of the applied tags has a webhook URL set in the Tags tab of that agent or campaign.

If the contact has both and the campaign's tags carry webhook URLs, those win; the agent's are used otherwise.

If multiple tags with different webhook URLs are applied in the same update, one request is sent per URL, each containing only the tags that map to that URL.

**Removing a tag never sends a request.** Most people point these URLs at an action — collect a deposit, book a slot, alert a rep — so a tag coming back off a contact used to re-run that action. It no longer can. A removal still shows up in `removed_tags` when it happens in the same update as an apply that goes to the same URL, so an automation that reads both arrays keeps the full picture; what it will never see is a request caused by a removal alone. (Changed **August 12, 2026**. Before that date, removals sent a request too.)

### Payload format

```json
{
  "event": "contact_tags_updated",
  "contact": {
    "id": "<contact-id>",
    "email": "jane@example.com",
    "phone_number": "+15551234567",
    "first_name": "Jane",
    "last_name": "Smith",
    "human_alerted": false,
    "is_bot_active": true,
    "ad_referral": {
      "ctwa_clid": "ARAbc123...",
      "source_id": "120210000000000",
      "source_type": "ad",
      "source_url": "https://fb.me/xxxx",
      "headline": "Get 20% off today",
      "body": "Message us now to claim your discount",
      "channel": "whatsapp"
    }
  },
  "added_tags": ["qualified-lead"],
  "removed_tags": ["new-lead"],
  "agent": {
    "id": "<agent-id>",
    "name": "Front Desk"
  },
  "user": {
    "email": "owner@example.com",
    "first_name": "Alex",
    "last_name": "Doe"
  }
}
```

| Field | Description |
|---|---|
| `event` | Always `contact_tags_updated` for this webhook. |
| `contact.id` | The unique ID of the contact whose tags changed. |
| `contact.email` / `contact.phone_number` | The contact's email/phone, if known. |
| `contact.first_name` / `contact.last_name` | The contact's name. |
| `contact.human_alerted` | Whether the contact is currently flagged for human attention. |
| `contact.is_bot_active` | Whether the AI bot is currently active on this contact's conversation. |
| `contact.ad_referral` | Present only when the contact first reached you through a Meta Click-to-WhatsApp (CTWA) ad or post. `null` otherwise. |
| `added_tags` | Array of tag names applied in this update. Never empty — an apply is what triggers the request. |
| `removed_tags` | Array of tag names removed in the same update, if any. A removal on its own does not send anything. |
| `agent` | The agent handling the contact's conversation (`id` and `name`), or `null` if no agent is involved. Added **15 August 2026**. |
| `user` | Basic identity information for the account that owns the contact. |

### Testing a tag webhook

Next to the webhook URL field on the Tags tab there is a **Test** button. It sends a sample payload to that URL immediately, so you can confirm your automation receives it before waiting on a real conversation.

The test sends the same `contact_tags_updated` shape shown above, using a placeholder contact, with the tag you are testing in `added_tags` and an empty `removed_tags`. What your automation sees in the test is what it will see in production.

Two things to know:

- **Save the tag first.** The test looks the tag up by its saved name, so a brand new tag or an unsaved rename can't be tested yet. The button stays greyed out until the name on screen matches the saved one.
- **A failed test doesn't count against your webhook.** Tests never contribute to the automatic switch-off after repeated failures described in [Webhook Reliability](#webhook-reliability).

If the test fails, the message tells you what your endpoint answered (for example a `404` or `500`), which is usually enough to spot a wrong URL or a workflow that isn't switched on.

---

## Task Completed Webhook

> **For reference only.** Task webhooks (as data) are documented here for developers; the **Task Created**, **Task Updated** and **Task Completed** events are selectable in the standard events list on the webhook form like any other event — see [Available Trigger Events](#available-trigger-events) and [The 22 Webhook Events](#the-22-webhook-events).

This payload is sent when a task transitions into a stage marked as a completion stage. A task moving between non-completion stages sends the `taskUpdated` shape instead.

### Event name

`taskCompleted`

### When it fires

- A task is updated.
- Its `stage` value changed compared to its previous value.
- The new stage is configured as a completion stage on the account's task stage settings.

### Payload format

```json
{
  "event": "taskCompleted",
  "contact": {
    "email": "jane@example.com",
    "phone_number": "+15551234567",
    "first_name": "Jane",
    "last_name": "Smith",
    "human_alerted": false,
    "human_alert_reason": null
  },
  "user": {
    "email": "owner@example.com",
    "first_name": "Alex",
    "last_name": "Doe"
  },
  "message": {
    "id": "<task-id>",
    "title": "Follow up with Jane",
    "description": "Confirm pricing and send proposal",
    "type": "follow_up",
    "priority": "high",
    "stage": "<stage-id>",
    "due_date": "2026-01-20T15:00:00Z",
    "source": "ai",
    "source_detail": "<source-detail>",
    "campaign_id": "<campaign-id>",
    "linked_human_alert": "<human-alert-id>",
    "tags": ["qualified-lead"],
    "notes": "Customer requested a callback"
  }
}
```

| Field | Description |
|---|---|
| `event` | Always `taskCompleted` for this webhook. The same payload shape is sent as `taskUpdated` when a task changes without entering a completion stage. |
| `contact` | The contact linked to the task, if any. `null` when not linked. |
| `contact.human_alert_reason` | The reason the contact was flagged for human attention, if applicable. |
| `user` | Basic identity information for the account that owns the task. |
| `message.id` | The unique ID of the task. |
| `message.title` / `description` | The task's title and description. |
| `message.type` | The task type (for example, `follow_up`, `call`, `custom`). |
| `message.priority` | The task priority (`low`, `medium`, `high`). |
| `message.stage` | The ID of the stage the task is now in. |
| `message.due_date` | The task's due date, if set. |
| `message.source` | What created the task (`ai`, `manual`, `api`). |
| `message.source_detail` | Additional detail about the source. |
| `message.campaign_id` | The ID of the linked campaign, or `null`. |
| `message.linked_human_alert` | The ID of the linked human alert, if any. |
| `message.tags` | Tags applied to the task. |
| `message.notes` | Free-form notes on the task. |

---

## Switching a Webhook Off (or Deleting It)

Every webhook has an on/off switch, right on its row. Switching one **off** stops it receiving events, but keeps everything you configured — the URL, the events, any signing secret. Switch it back on and it picks up where it left off; nothing that happened while it was off is delivered afterwards.

Use it when you want deliveries to stop for a while: your endpoint is being rebuilt, you're debugging a noisy integration, or you're pausing an automation.

**Deleting** a webhook (the trash icon on its row) removes it for good, including its signing secret. If you only want deliveries to stop, switch it off instead — delete is for when you're done with the endpoint entirely.

> **This is not the same as a webhook being switched off automatically.** If we disable your webhook after repeated failures (see [Webhook Reliability](#webhook-reliability)), the toggle above won't bring it back. Once your endpoint is fixed, edit the webhook and save it with a changed URL (any URL change re-enables it), or call the [re-enable endpoint](../api/webhooks.md) over the API — or ask support and we'll flip it back for you.

---

## Signed Payloads (Verifying a Webhook Really Came From Us)

Anyone who learns your webhook URL could send a fake request to it. If you act on webhooks automatically — updating billing, creating CRM records — turning on **signing** lets you verify each request genuinely came from us.

Signing is **optional and off by default**, and you switch it on per webhook, from that webhook's edit view (open a saved webhook's row).

### Turning on signing

1. Open the webhook (Settings → Integrations → Webhooks → click your webhook's row).
2. In the **Signing secret** section, click **Generate**.
3. Copy the secret (it starts with `whsec_`) and store it in your receiving system. Treat it like a password.

You can come back and reveal, copy, rotate, or turn off the secret any time from this same panel.

### What we send

Once signing is on, every delivery for that webhook carries these two extra HTTP headers:

| Header | Meaning |
|---|---|
| `X-Webhook-Signature` | The signature, in the form `v1=<hex>`. |
| `X-Webhook-Timestamp` | When we sent it, as a Unix timestamp in seconds. |

These three are on **every** delivery, signed or not:

| Header | Meaning |
|---|---|
| `X-Webhook-Delivery` | A unique ID for this event. Stays the same across retries, so it's what you dedupe on. |
| `X-Webhook-Attempt` | Which attempt this is (`1` is the first try). |
| `X-Webhook-Event` | The event name, so you can route without reading the body. |

### How to verify

The signature is an HMAC-SHA256 of the string `<timestamp>.<raw request body>`, using your signing secret as the key.

**Verify against the raw request body — the exact bytes you received.** If your framework parses the JSON and re-serializes it before checking, the bytes can change and the signature won't match.

Node.js example:

```js
const crypto = require("crypto");

function verify(rawBody, headers, secret) {
  const timestamp = headers["x-webhook-timestamp"];
  const signature = headers["x-webhook-signature"]; // "v1=<hex>"

  // Reject anything older than 5 minutes so a captured request can't be replayed later.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");

  return crypto.timingSafeEqual(Buffer.from(signature.replace("v1=", "")), Buffer.from(expected));
}
```

Python example:

```python
import hashlib, hmac, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers["X-Webhook-Timestamp"]
    signature = headers["X-Webhook-Signature"].replace("v1=", "")

    # Reject anything older than 5 minutes so a captured request can't be replayed later.
    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()

    return hmac.compare_digest(signature, expected)
```

> **Compare signatures with a timing-safe function** (`timingSafeEqual` / `compare_digest`), not `==`. It costs nothing and avoids a subtle class of attack.

### Rotating the secret

Click **Rotate** to replace the secret. The switch is immediate: the very next delivery is signed with the new secret only. If your endpoint is live, accept **both** the old and the new secret for a few minutes while you deploy the new one.

Turning signing off simply stops the signature headers being sent.

---

## Retrying Failed Deliveries

By default, a delivery that fails is not retried — if your system is down at that moment, that event is missed.

Turn on **Retry failed deliveries** on a webhook (in the create/edit form) and we'll keep trying:

| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 1 minute later |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |

That spans roughly **2 hours and 40 minutes**, so a webhook can survive a maintenance window or a short outage on your side.

**What gets retried:** temporary problems — your server returning a 5xx error, a timeout, or a connection failure.

**What does not:** if your endpoint rejects the request itself (any 4xx), we don't retry — sending the identical request again would only produce the identical rejection.

**Which events retry:** tag webhooks (`contact_tags_updated`), the three task events, and the daily summary. The rest are sent once, so for those the switch has nothing to act on. Every event still carries `X-Webhook-Delivery`, so one dedupe rule covers all of them.

> **Turn on retries only if your endpoint is idempotent.** Retries mean the same event can arrive more than once. Use the `X-Webhook-Delivery` header to recognise a repeat: it stays the same across every attempt for one event, so you can safely ignore an ID you've already handled.

Retries interact with the automatic switch-off after repeated failures (see [Webhook Reliability](#webhook-reliability)) in the way you'd want: the failure counter counts a **whole delivery**, only after every retry has been used up — not each individual attempt.

---

## Webhook Reliability

- <span data-t="appName">DM Champ</span> sends webhooks over a secure connection (HTTPS). Make sure the web address you provide uses HTTPS.
- If your system returns an error, the delivery is considered failed.
- Monitor your receiving system's uptime to avoid missing events.
- For critical workflows, turn on [Retrying Failed Deliveries](#retrying-failed-deliveries), and consider a fallback mechanism as well.

> **Webhooks are switched off automatically after repeated failures.** If your webhook URL fails repeatedly (about 5 errors in a row, or 3 in a row for configuration-type errors), <span data-t="appName">DM Champ</span> automatically stops sending events to that URL. To bring it back once your endpoint is healthy: edit the webhook and save it with a changed URL (any URL change re-enables it), or use the [re-enable endpoint](../api/webhooks.md) over the API — re-saving with the same URL is not enough. Support can also re-enable it for you.

---

## Troubleshooting

| Problem | Solution |
|---|---|
| Webhook not firing | First check the webhook isn't switched **off** on its row. Then confirm the correct events are selected and your URL is reachable from the internet. |
| Test event works but real events do not | Make sure the specific event type is enabled. If you expected a request when a tag is applied, note that `subscribed_to_tags` does not scope a webhook's events to a tag — it only narrows which tags produce a conversation-summary notification. To get a request when a specific tag is applied, set a webhook URL on that tag in the **Tags** tab of the agent (or campaign) — see [Contact Tags Updated Webhook](#contact-tags-updated-webhook). |
| Nothing arrives in n8n / Make / Zapier | You're probably using the platform's **Test URL**, which only listens for a single event right after clicking "Listen for test event." For live events, save the **Production URL** and switch the workflow to **Active**. |
| Receiving duplicate events | Check for multiple webhooks pointing at the same URL. If **Retry failed deliveries** is on, a repeat is expected whenever your endpoint accepted an event but failed to answer in time — dedupe on `X-Webhook-Delivery`. |
| Signature check always fails | Almost always because the body was re-serialized before checking. Verify against the **raw** request body, sign `<timestamp>.<body>`, and confirm you're using the current secret if you recently rotated. |
| Retries not happening | Retries are off unless enabled on that specific webhook. We don't retry 4xx responses. |
| The `campaign` block is always `null` | Expected if your account uses agents: contacts sit with an agent rather than a campaign. Read the `agent` block instead — see [Webhook Data Format](#webhook-data-format). |
| Data is empty or malformed | Verify your receiving system accepts JSON. Check your server logs for parsing errors. |
| Webhook URL returns errors | Test your URL with a tool like Postman or [webhook.site](https://webhook.site). |
| Webhook stopped firing entirely after an outage | Repeated failures automatically disable a webhook. Re-saving does not re-enable it — fix your endpoint, then contact support. |
| Save or Test gives a permission error | You need the Integrations "edit" permission. Ask the account owner to grant it. |
| A webhook's `subscribed_to_tags` list came back empty | `subscribed_to_tags` does not scope a webhook's events to a tag — it only narrows which tags produce a conversation-summary notification. Editing from the webhook form no longer clears that list (fixed July 21, 2026). If a webhook lost its list before that date, set `subscribed_to_tags` again via the [Webhooks API](../api/webhooks.md) — see [Tag-Based Webhook Triggers](#tag-based-webhook-triggers). |

---

## Next Steps

- [GoHighLevel Integration](ghl-integration.md) — use webhooks to integrate <span data-t="appName">DM Champ</span> with GHL.
- [API Access](api-access.md) — combine webhooks with the API for powerful automations.
- [Using Tags to Label Contacts](../get-started/creating-tags.md) — set up tags that trigger your webhooks.
