Webhooks
Webhooks let Your AI Connector 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.
What Are Webhooks?
Think of a webhook like an automatic text message between two apps. When something happens in Your AI Connector (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 Your AI Connector. A webhook is a one-way street from Your AI Connector 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 (the Create a Contact operation) and Funnels. The only thing you need for the inbound direction is your API key, which lives in its own section — see API Access. The Webhooks page described here is exclusively for the outbound direction.
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
- In the left sidebar, click Settings (gear icon).
- In the Settings sidebar, under the Integrations group, click Webhooks.
On an account with no webhooks configured yet, the page looks like this:
- Click New webhook, top-right. A form opens inline on the page:
- Fill in:
- Endpoint URL — the web address Your AI Connector 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. Plainhttp://addresses,localhostor 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.
- Under Events, click the events you want this webhook to receive — all 22 are listed in The 22 Webhook Events.
- (Optional) Turn on Retry failed deliveries if you want Your AI Connector to keep trying on a temporary failure — see Retrying Failed Deliveries.
- 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 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
userblock tells you which client an event belongs to. Every notification already carries auserblock 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), 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.
Available Trigger Events
You can enable or disable each of the 22 webhook events independently. When an event fires, Your AI Connector 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 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 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, or by asking support.
Good to know: editing an existing webhook that has a
subscribed_to_tagslist (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 itssubscribed_to_tagslist 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, Your AI Connector 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
- Open Settings → Integrations → Webhooks.
- On your webhook’s row, click Test.
- Check your external system to confirm it received the test data.
- 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: Use a tool like webhook.site or RequestBin 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. 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=abc123is 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 Your AI Connector'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.0user 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 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 Your AI Connector 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 Your AI Connector and click Test once. - A Production URL (in n8n it contains
/webhook/, no-test). This is the one to paste into Your AI Connector 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 Your AI Connector 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 Your AI Connector and make sure the workflow is Active.
Webhook Data Format
When a webhook fires, Your AI Connector 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:
{
"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. |
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. |
campaignoragent— usually one, not both. If your account uses agents, your contacts sit with an agent rather than a campaign, socampaignarrives asnullandagenttells you which one handled it. Older campaign-based accounts see the reverse. Read whichever one is filled in; don’t assumecampaignis always there.
The
agentblock arrived on 15 August 2026. It sits alongsidecampaignon 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’sidandname, ornullwhen 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), New Message adds a full message block with the text (see 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 tell you which message, but not what it said. They carry a
messageblock containing the message’sidandstatus— and thatidis the samemessageIdthe send message endpoint hands back, so you can match a delivery or read receipt to the exact message you sent — but no message body. Replies carries nomessageblock 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
timestampfield, and nodatawrapper. 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). |
| 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 | 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. |
| 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 and Task Completed.
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
{
"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. |
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
{
"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. |
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). |
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
campaignblock in this payload. New Message sendscontact,agent,userandmessage. Theagentblock 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 usingcontact.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 Your AI Connector: 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
{
"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 returns as messageId, and the same message.id a New Message 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
messageIdyou get back when you send a message through the API. When a Deliveries or Reads notification arrives, look up that stored ID againstmessage.idin the payload — that’s your delivery or read receipt for that exact message.
There is no message text here. The
messageblock carries only the ID and the status. Subscribe to New Message if you also need the body.
The
messageblock 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 thatmessageexists before readingmessage.id.
One notification per status change. A single outgoing message normally produces a
deliverednotification and then, on channels with read receipts, areadone. A failed send producesundeliveredinstead.
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
{
"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_idis oftennullin 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 itsappointment_ida moment later if you need it. It staysnullpermanently if the account has no Google Calendar connected, so don’t wait on it forever.
The “Test” button does not include the
appointmentblock. 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
{
"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.
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 and 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
stagevalue changed compared to its previous value. - The new stage is configured as a completion stage on the account’s task stage settings.
Payload format
{
"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), 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 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
- Open the webhook (Settings → Integrations → Webhooks → click your webhook’s row).
- In the Signing secret section, click Generate.
- 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:
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:
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-Deliveryheader 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) 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
- Your AI Connector 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, 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), Your AI Connector 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 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. |
| 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. |
| 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. |
| 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 — see Tag-Based Webhook Triggers. |
Next Steps
- GoHighLevel Integration — use webhooks to integrate Your AI Connector with GHL.
- API Access — combine webhooks with the API for powerful automations.
- Using Tags to Label Contacts — set up tags that trigger your webhooks.