Webhooks

    7 min read
    ·
    381 view
    ·
    Updated 10/21/2026

    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

    1. You register your server URL via PATCH /v1/webhooks
    2. WazzaBee sends a test request to your URL: if it does not return 200, the registration is rejected
    3. When an event happens, WazzaBee sends a POST request to your URL with the event data
    4. 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

    ParameterTypeRequiredDescription
    webhooksUristring (URL)✅ YesThe 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)
    subscriptionsarray of stringsNoThe list of events to subscribe to. Default: ["messages_incoming"]
    headersobjectNoYour 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

    EventDescription
    messages_incomingIncoming messages from customers
    messages_outgoingOutgoing messages (sent via the API or by hand)
    messages_statusStatuses of outgoing messages: sent, delivered, read, failed. Format below, in «Delivery statuses»
    messages_buttonTemplate button clicks as a separate event, with the campaign and broadcast the customer replied to. Format below, in «Button clicks»
    contactsAndDealsCreationNew contacts and deals created
    channelsUpdatesChannel 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)."
    }
    🔴 Important! The 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.

    The challenge field (recommended). The test request contains a 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

    CodeErrorReason
    400The webhooksUri field is requiredNo URL was sent
    400webhooksUri must be a valid URLThe URL format is incorrect
    400webhooksUri must use https://The address does not start with https://
    400webhooksUri cannot point to a local or internal addressThe URL leads to localhost, a private network or a metadata address
    400The test request returned error NYour server returned a non-200 response
    400Could not send the test requestThe 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

    FieldTypeDescription
    webhooksUristringThe registered URL
    subscriptionsarrayActive subscriptions
    statusstringactive or disabled
    failureCountintegerNumber of notifications in a row not delivered after all retries
    createdAtstring (ISO 8601)Creation date
    updatedAtstring (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

    HeaderDescription
    X-Webhook-SignatureHMAC-SHA256 signature of the request body, verification is described below
    X-Webhook-Delivery-IdNotification ID. The same for the first request and all retries
    X-Webhook-AttemptAttempt number: 1 for the first request, 2 and above for retries
    How to avoid processing a notification twice. Store the 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"
      }]
    }
    statusMeaning
    sentAccepted by WhatsApp
    deliveredDelivered to the subscriber's phone
    readRead
    failedNot 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

    FieldTypeDescription
    channel_idstring (UUID)The WazzaBee channel identifier
    channel_typestringChannel type: whatsapp_waba, whatsapp_qr, telegram_bot, telegram_personal
    channel_namestringChannel name
    conversation_idstring (UUID) | nullConversation identifier. Use it to group messages from the same customer
    messageobjectMessage data
    operatorobject | nullThe last agent who replied in this chat. null if nobody has replied yet
    operator.crm_user_idstringThe user ID from your CRM (passed when the iframe session was created)
    operator.crm_user_namestringThe agent's name. We take it from user.name of your iframe session, and for messages sent via the API, from the senderName field
    adobject | absentWhere 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
    rawobjectThe full original payload from the provider (for debugging)
    The messages array in the same request. Along with the fields above, the request contains a 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.

    FieldTypeDescription
    ad_idstring | nullThe ad identifier. Use it to find the ad in your Meta ads manager
    source_typestring | nullad: an ad, post: a regular post
    headlinestring | nullThe ad headline. It can repeat across different ads. Use ad_id for an exact match
    textstring | nullThe ad text
    media_typestring | nullCreative type: image or video
    image_urlstring | nullThe ad image or a video frame
    video_urlstring | nullThe ad video
    urlstring | nullA link to the ad
    click_idstring | nullThe 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
    What to do with this. Write ad_id and click_id into the customer record on your side. Then your reports will show which ad brings deals, not just enquiries.
    The operator field helps your CRM route notifications more precisely. If an agent has already replied to the customer, you know who to send the new incoming message to.

    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

    ValueWhen it arrives
    textA regular text message
    image, video, audio, document, stickerMedia
    location, contacts, reactionWhatsApp service types
    buttonA tap on a Quick Reply button of a WABA template (the older Meta format)
    interactiveA 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": { ... }
    }
    ✅ Backwards compatible with older integrations. The 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

    FieldTypeDescription
    author.kindstringHow it was sent: operator: a person replied, api: your system sent the message, automation: a bot or auto-reply, broadcast: a broadcast
    author.namestring | nullThe 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.userIdstring | nullThe 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
    ✅ How to get the author's name. Nothing extra to set up: pass 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.