Webhooks: event notifications
Webhooks let you receive HTTP notifications on your server when events happen: incoming messages, outgoing messages, status changes and more.
How it works
- You register your server URL via
PATCH /v1/webhooks - WazzaBee sends a test request to your URL: if it does not return
200, the registration is rejected - When an event happens, WazzaBee sends a POST request to your URL with the event data
- Every request is signed with HMAC-SHA256, so you can verify that it is genuine
Two ways to connect a webhook
1. From your dashboard (simple, no developer needed). Open "Connectors" → "API" → the "Webhook for incoming messages" block. Paste your server address and click "Connect". The dashboard will check the address itself and show you the secret key used for signing.
2. Via the REST API (for automation). Send a PATCH /v1/webhooks request, see below.
A webhook is set up once, so it is easier to send this request from your server. If you send it from a browser and get Failed to fetch, check that the method is written in capital letters (PATCH) and that your URL answers the test request with 200 within 10 seconds.
PATCH /v1/webhooks: register or update
URL
PATCH https://api.wazzabee.com/api-gateway/v1/webhooks
Headers
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
Request body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
webhooksUri | string (URL) | ✅ Yes | The URL on your server that will receive notifications. https only, it must be reachable from the internet and return 200 for POST requests. It cannot point to local or internal addresses (localhost, 127.0.0.1, 10.x, 172.16–31.x, 192.168.x, 169.254.x) |
subscriptions | array of strings | No | The list of events to subscribe to. Default: ["messages_incoming"] |
headers | object | No | Your own headers added to every notification, up to 10. For example {"Authorization": "Bearer ..."} to authorize on your server, or your system identifier. Service headers (Content-Type, X-Webhook-*) cannot be set. Not passed: the current ones stay; null or {}: removed. Static parameters can be put right in webhooksUri after ? |
Available subscriptions
| Event | Description |
|---|---|
messages_incoming | Incoming messages from customers |
messages_outgoing | Outgoing messages (sent via the API or by hand) |
messages_status | Statuses of outgoing messages: sent, delivered, read, failed. Format below, in «Delivery statuses» |
messages_button | Template button clicks as a separate event, with the campaign and broadcast the customer replied to. Format below, in «Button clicks» |
contactsAndDealsCreation | New contacts and deals created |
channelsUpdates | Channel status changes (connected, disconnected, error) |
Request example
curl -X PATCH "https://api.wazzabee.com/api-gateway/v1/webhooks" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhooksUri": "https://your-server.com/webhook/wazzabee",
"subscriptions": [
"messages_incoming",
"messages_outgoing",
"messages_status"
]
}'
Successful response (200 OK)
{
"success": true,
"webhooksUri": "https://your-server.com/webhook/wazzabee",
"subscriptions": [
"messages_incoming",
"messages_outgoing",
"messages_status"
],
"secret": "a1b2c3d4e5f6...64-character-hex-key",
"message": "Webhook is set up. Save the secret: it is used to sign notifications (HMAC-SHA256)."
}
secret field is generated anew on every PATCH call. Be sure to save it: you need it to verify the signature of incoming notifications.
Test request
When you register a webhook, WazzaBee immediately sends a test POST request to your URL:
{
"test": true,
"event": "webhook_test",
"challenge": "random_string_for_confirmation",
"timestamp": "2026-03-12T10:00:00.000Z",
"message": "WazzaBee webhook test: if you see this, your webhook works correctly."
}
Your server must reply with status 200. If the response is not 200 or the URL is unreachable, the webhook registration is rejected with an error.
challenge field with a random string. If your server returns the same value, in the response body or as the JSON field challenge (for example {"challenge": "the same value"}), the webhook is marked as "verified" (a higher level of trust). This is not mandatory: if your receiver (a ready-made no-code service, say) cannot return the value, the webhook will still connect and work, protected by the HMAC signature.
Possible registration errors
| Code | Error | Reason |
|---|---|---|
400 | The webhooksUri field is required | No URL was sent |
400 | webhooksUri must be a valid URL | The URL format is incorrect |
400 | webhooksUri must use https:// | The address does not start with https:// |
400 | webhooksUri cannot point to a local or internal address | The URL leads to localhost, a private network or a metadata address |
400 | The test request returned error N | Your server returned a non-200 response |
400 | Could not send the test request | The URL is unreachable or timed out (10 seconds) |
GET /v1/webhooks: current settings
URL
GET https://api.wazzabee.com/api-gateway/v1/webhooks
Request example
curl -X GET "https://api.wazzabee.com/api-gateway/v1/webhooks" \
-H "Authorization: Bearer YOUR_API_KEY"
Response: webhook is configured (200 OK)
{
"success": true,
"webhook": {
"webhooksUri": "https://your-server.com/webhook/wazzabee",
"subscriptions": ["messages_incoming", "messages_outgoing"],
"status": "active",
"failureCount": 0,
"createdAt": "2026-03-12T08:00:00.000Z",
"updatedAt": "2026-03-12T08:00:00.000Z"
}
}
Response: webhook is not configured (200 OK)
{
"success": true,
"webhook": null,
"message": "Webhook is not configured"
}
Field reference
| Field | Type | Description |
|---|---|---|
webhooksUri | string | The registered URL |
subscriptions | array | Active subscriptions |
status | string | active or disabled |
failureCount | integer | Number of notifications in a row not delivered after all retries |
createdAt | string (ISO 8601) | Creation date |
updatedAt | string (ISO 8601) | Date of the last update |
Retries
If your server does not respond with a 2xx code within 10 seconds (returns an error, is unreachable or times out), WazzaBee sends the same notification again. The pauses between attempts grow: 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours. That is 8 attempts over roughly 24 hours.
Headers of every request
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature of the request body, verification is described below |
X-Webhook-Delivery-Id | Notification ID. The same for the first request and all retries |
X-Webhook-Attempt | Attempt number: 1 for the first request, 2 and above for retries |
X-Webhook-Delivery-Id of processed notifications and skip requests with an ID you have already seen. A retry may arrive even if the first request got through but your server answered after more than 10 seconds. Respond with 200 right away and do long processing after the response.
A retry may arrive after later notifications. Restore the order of messages by the timestamp field.
When the webhook is disabled
A notification counts as undelivered if all 8 attempts failed. After 10 undelivered notifications in a row the webhook switches to disabled. Any successful delivery resets the counter. Every attempt is shown in the delivery log in your account: "Connectors" → "API".
Delivery statuses (messages_status)
For every outgoing message on an official WhatsApp (WABA) line you get a notification when its status changes. queueId matches the queueId returned by POST /v1/messages/send: use it to match the status to your send. If you passed campaignId when sending, it comes back too.
{
"statuses": [{
"messageId": "uuid",
"queueId": "uuid",
"campaignId": "promo_oct",
"channelId": "uuid",
"chatId": "77071234567",
"conversationId": "uuid",
"contactId": "uuid",
"status": "delivered",
"error": null,
"timestamp": "2026-09-30T12:00:00.000Z"
}]
}
| status | Meaning |
|---|---|
sent | Accepted by WhatsApp |
delivered | Delivered to the subscriber's phone |
read | Read |
failed | Not delivered. error holds the code and a readable reason, e.g. {"code": 131026, "description": "..."} |
The same data is available in GET /v1/messages: the status, sentAt, deliveredAt, readAt and error fields.
Button clicks (messages_button)
When a subscriber taps a template button, a separate event is sent in addition to the regular incoming message. replyTo already tells you which outgoing message they replied to: queueId, campaignId (your label from the API send) and broadcastId (if the template went out as a broadcast from the dashboard). No need to look up the original message by context yourself.
{
"buttons": [{
"messageId": "uuid",
"channelId": "uuid",
"chatId": "77071234567",
"contactId": "uuid",
"conversationId": "uuid",
"button": { "type": "button", "id": null, "title": "Yes, interested", "payload": "YES_INTERESTED" },
"replyTo": {
"messageId": "uuid",
"queueId": "uuid",
"campaignId": "promo_oct",
"broadcastId": null
},
"timestamp": "2026-09-30T12:00:00.000Z"
}]
}
replyTo is null if the original message was not found (for example, it was not sent through WazzaBee).
Custom headers and parameters in the address
If your server only accepts notifications with authorization, pass the headers in the headers field when registering the webhook. They go with every notification, every retry and the test request. Static data (for example, your system identifier) can be added right to the address: https://your-server.com/hook?source=wazzabee&system=crm. Headers can also be set in the dashboard: «Connectors» → «API» → the webhook block.
{
"webhooksUri": "https://your-server.com/hook?source=wazzabee",
"subscriptions": ["messages_incoming", "messages_status", "messages_button"],
"headers": { "Authorization": "Bearer your-token" }
}
Verifying the signature (HMAC-SHA256)
Every notification is signed with the secret you received when registering the webhook. We recommend always verifying the signature to protect yourself from forged requests.
Verification example in Node.js
const crypto = require("crypto");
function verifyWebhookSignature(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(JSON.stringify(body))
.digest("hex");
return signature === expected;
}
// Inside the webhook handler:
app.post("/webhook/wazzabee", (req, res) => {
const signature = req.headers["x-webhook-signature"];
if (!verifyWebhookSignature(req.body, signature, YOUR_WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
// Handle the event...
res.status(200).send("OK");
});
Verification example in Python
import hmac
import hashlib
import json
def verify_signature(body: dict, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
json.dumps(body).encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Inside the Flask handler:
@app.route("/webhook/wazzabee", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Webhook-Signature", "")
if not verify_signature(request.json, signature, YOUR_WEBHOOK_SECRET):
return "Invalid signature", 401
# Handle the event...
return "OK", 200
Incoming webhook notification format
When an incoming message arrives, WazzaBee sends a POST request to your URL with the following structure:
{
"channel_id": "uuid",
"channel_type": "whatsapp_waba",
"channel_name": "WhatsApp Business",
"conversation_id": "uuid",
"message": {
"id": "msg_id",
"from": "77771234567",
"sender_name": "Customer",
"type": "text",
"text": "Hello!",
"timestamp": "1712345678"
},
"operator": {
"crm_user_id": "manager-42",
"crm_user_name": "Aleksey, agent"
},
"ad": {
"ad_id": "120255518599590051",
"source_type": "ad",
"headline": "Visa4U",
"text": "Visa denied? All is not lost!",
"media_type": "video",
"image_url": "https://scontent.xx.fbcdn.net/...jpg",
"video_url": "https://video.xx.fbcdn.net/...mp4",
"url": "https://fb.me/2abcXYZ",
"click_id": "AfgHqhgPqgcChoXjlCqLkHfWKeS8"
},
"raw": { ... }
}
Field reference
| Field | Type | Description |
|---|---|---|
channel_id | string (UUID) | The WazzaBee channel identifier |
channel_type | string | Channel type: whatsapp_waba, whatsapp_qr, telegram_bot, telegram_personal |
channel_name | string | Channel name |
conversation_id | string (UUID) | null | Conversation identifier. Use it to group messages from the same customer |
message | object | Message data |
operator | object | null | The last agent who replied in this chat. null if nobody has replied yet |
operator.crm_user_id | string | The user ID from your CRM (passed when the iframe session was created) |
operator.crm_user_name | string | The agent's name. We take it from user.name of your iframe session, and for messages sent via the API, from the senderName field |
ad | object | absent | Where the enquiry came from: which ad brought the customer. This block only arrives for enquiries from Click-to-WhatsApp ads on official WhatsApp (WABA) channels. Field reference below |
raw | object | The full original payload from the provider (for debugging) |
messages array in the format described below in the messages_outgoing section, with "direction": "incoming". This is one notification about one message: read whichever format suits you, you do not need to parse the other. The unique notification ID comes in the X-Webhook-Delivery-Id header.
The ad block: enquiry source
If a customer tapped "Send message on WhatsApp" in an Instagram or Facebook ad, the notification comes with an ad block. Regular enquiries do not have it. Check whether the field itself exists rather than the values inside it. The block only arrives on official WhatsApp channels: Meta passes this data for WABA only.
| Field | Type | Description |
|---|---|---|
ad_id | string | null | The ad identifier. Use it to find the ad in your Meta ads manager |
source_type | string | null | ad: an ad, post: a regular post |
headline | string | null | The ad headline. It can repeat across different ads. Use ad_id for an exact match |
text | string | null | The ad text |
media_type | string | null | Creative type: image or video |
image_url | string | null | The ad image or a video frame |
video_url | string | null | The ad video |
url | string | null | A link to the ad |
click_id | string | null | The ad click identifier (ctwa_clid). Save it on your side: it is what you send sale events back to Meta with, so the ads optimise for buyers |
ad_id and click_id into the customer record on your side. Then your reports will show which ad brings deals, not just enquiries.
Customer replies to WABA template buttons
When you send a customer an approved WABA template with Quick Reply buttons (or Interactive buttons / list) and the customer taps a button, WazzaBee sends the extra fields message.interactive and message.context in the webhook. The message.text field contains the real text of the button that was tapped. You can show it in the CRM chat as a regular customer message.
Values of message.type
| Value | When it arrives |
|---|---|
text | A regular text message |
image, video, audio, document, sticker | Media |
location, contacts, reaction | WhatsApp service types |
button | A tap on a Quick Reply button of a WABA template (the older Meta format) |
interactive | A tap on an Interactive button / list (Meta Cloud API v3) |
Structure of the message.interactive field
{
"type": "button" | "button_reply" | "list_reply",
"id": "button identifier" | null,
"title": "the button text the customer saw" | null,
"description": "description (list_reply only)" | null,
"payload": "payload value (type=button only)" | null
}
Structure of the message.context field
A reference to the original HSM message whose button the customer tapped:
{
"id": "uuid of the outgoing message in WazzaBee",
"from": "sender number (your WABA number)",
"gs_id": "internal provider message ID",
"meta_msg_id": "wamid.HBgL...: the ID in Meta"
}
Webhook example: the customer tapped "Yes, I'll be on time"
{
"channel_id": "f0e8...",
"channel_type": "whatsapp_waba",
"channel_name": "WhatsApp Business",
"conversation_id": "b1c2...",
"message": {
"id": "wamid.HBgLNzcwMTEwMDEwNTkVAgASGBQzQTY5NkJCNjE3M0JGQjE1MjVEMQA=",
"from": "77011001059",
"sender_name": "Farkhat",
"type": "button",
"text": "Yes, I'll be on time",
"interactive": {
"type": "button",
"title": "Yes, I'll be on time",
"payload": "Yes, I'll be on time"
},
"context": {
"id": "584abab5-911a-49ca-a750-95e8116be1e1",
"from": "77079119977",
"gs_id": "378ba4ed-5a5d-4c58-a4e8-b6ca08b2ad7a",
"meta_msg_id": "wamid.HBgL..."
},
"timestamp": "1776870877"
},
"operator": null,
"raw": { ... }
}
message.text field now contains the real text of the button that was tapped. Older clients that only read text will see a meaningful message "Yes, I'll be on time" in the chat instead of a technical placeholder.
Example: a tap on an Interactive list (Meta Cloud API v3)
{
"message": {
"type": "interactive",
"text": "Home delivery",
"interactive": {
"type": "list_reply",
"id": "delivery_home",
"title": "Home delivery",
"description": "To the customer's address"
},
"context": { "meta_msg_id": "wamid.HBgL..." }
}
}
Outgoing WABA templates (messages_outgoing)
When you send an HSM template via the API or from the WazzaBee interface and you are subscribed to messages_outgoing, the webhook receives a structure with a template field (if the template has buttons):
{
"messages": [{
"messageId": "uuid",
"direction": "outgoing",
"type": "template",
"text": "Hello! Please confirm your appointment tomorrow at 15:00.",
"template": {
"name": "appointment_reminder_v2",
"language": "ru",
"buttons": [
{ "type": "quick_reply", "text": "Yes, I'll be on time" },
{ "type": "quick_reply", "text": "Reschedule" }
]
},
"timestamp": "2026-04-22T15:14:42Z"
}]
}
This lets your CRM render the buttons in the chat feed exactly as the customer sees them in WhatsApp.
Who sent the outgoing message (author)
The messages_outgoing event fires for any reply to a customer: written by an agent in the WazzaBee dashboard, sent from the chat embedded in your CRM, sent as a template or through POST /v1/messages/send. The message comes with an author block that says exactly who replied.
{
"messages": [{
"messageId": "uuid",
"channelId": "uuid",
"chatId": "77001234567",
"contactId": "uuid",
"conversationId": "uuid",
"direction": "outgoing",
"type": "text",
"text": "Hello! Your documents are ready.",
"author": {
"kind": "operator",
"name": "Nazerke A.",
"userId": "manager-42"
},
"timestamp": "2026-07-30T09:20:12.000Z"
}]
}
Fields of the author block
| Field | Type | Description |
|---|---|---|
author.kind | string | How it was sent: operator: a person replied, api: your system sent the message, automation: a bot or auto-reply, broadcast: a broadcast |
author.name | string | null | The agent's name. For replies from the embedded chat it is the name you passed in user.name when creating the iframe session; for API sends it is senderName. null if no name was passed |
author.userId | string | null | The agent's identifier in your CRM (user.id from the iframe session, or userId for API sends). We return it as is: you do not need to map it to WazzaBee users |
user.id and user.name when creating the iframe session, and the agent's name will appear both in the signature under the message in the chat and in this webhook. For messages your system sends itself, pass senderName and userId in the send request.
