Product guides
Incoming message webhooks (listeners)
A listener connects one of your WhatsApp numbers to Unibox and can send an incoming message webhook to your server. In the dashboard, listeners are managed on the Incoming Triggers page. Once a number is a listener, its chats sync into the Unibox inbox, and, if you add a webhook URL, Wbiztool sends each new message to your server. Use webhooks to log conversations in a CRM, alert your team or build an auto-reply.
Before you start#
- The Unibox add-on. It costs $20/month or $200/year per WhatsApp number, and the number of listeners you can have equals the add-on quantity. Buy it under Available Add-ons on Billing & Plans inside the dashboard. Without it, the Incoming Triggers page still opens, but clicking Add New Listener shows Unibox Add-on Required with a Subscribe to Unibox Add-on button.
- A connected WhatsApp number on WhatsApp settings. Only connected numbers that aren't listeners yet can be added.
- You must be an owner or editor of the workspace.
- For webhooks: a public URL (use
https) of 100 characters or fewer that acceptsPOSTrequests with a JSON body.
Add a listener#
Open Incoming Triggers
In the sidebar, open Unibox and click Incoming Triggers, or go to Incoming Triggers.
Start a new listener
Click the Add New Listener card.
Choose the number
Pick it in Select WhatsApp Number. If the list says No available WhatsApp numbers, every connected number is already a listener, or none is connected.
Add a webhook (optional)
Enter your Webhook URL (Optional). Once you type a URL, Webhook Events appears: keep Incoming Messages, Outgoing Messages or both ticked. You can add or change the URL later.
Save
Click Add Listener. The listener appears as a card with the status Active. If you entered a URL, a webhook secret is created for it.
Manage listeners#
Each card shows the number, its status, the Webhook URL: (or Not configured), Last Activity: (when the number was last checked for messages, or Never) and the Webhook Secret:, hidden until you click the eye button.
Open the ⋮ menu on a card to:
| Action | What happens |
|---|---|
| Edit | Change the webhook URL, the webhook events or the secret. The number can't be changed. |
| Disable / Enable | Disabling sets the listener to Inactive: the number stops syncing to the inbox and no webhooks are sent. Enabling makes it Active again. |
| Delete | Removes the listener after you confirm. Conversations already in the inbox stay. Adding the same number again later restores the listener. If you enter a webhook URL when re-adding it, the listener keeps its previous webhook secret, if it had one, instead of getting a new one. |
Listener statuses#
| Status | Meaning |
|---|---|
| Active | Messages sync and webhooks are sent while the number is connected. |
| Pending | The number wasn't connected when the listener was created, for example by Zapier. Click Enable once the number is connected. |
| Inactive | Disabled. Nothing syncs and no webhooks are sent. |
Stats#
| Card | What it shows |
|---|---|
| Active Listeners | All listeners on the page, including disabled ones. |
| Available Numbers | Connected numbers that aren't listeners yet. Numbers whose listener you deleted still count as used here, so this can show fewer than you can actually add. |
| Total Limit | How many listeners your Unibox add-on allows. |
| Messages Today | Not tracked yet; always shows 0. |
Change the webhook URL or events#
Open the listener
Click ⋮ on the card, then Edit.
Update the settings
Change the Webhook URL (Optional) and the Webhook Events. Clear the URL to stop webhooks for this number: its secret is removed too, and a new one is created if you add a URL again.
Save
Click Update Listener.
Regenerate the secret#
In Edit Listener, click the refresh button next to Webhook Secret and confirm. The new secret is saved straight away, even if you then close the dialog without clicking Update Listener, and requests are signed with it from then on. Update your server with the new secret immediately.
How webhooks are delivered#
Wbiztool sends one POST request to your URL for each new message found when the number syncs, which happens every few minutes while the number is connected and isn't busy sending messages.
- Events:
message_receivedfor messages people send to your number, andmessage_sentfor messages sent from it (from the phone, campaigns or the API). Only the events ticked in Webhook Events are sent. - Replies from the Unibox inbox usually don't trigger
message_sent, because the inbox already has them when the sync runs. - Response: reply with HTTP
200within 8 seconds. Any other response or a timeout counts as a failed delivery. - No retries: each message is sent once. If your server is down, that webhook is lost.
- Order: requests are sent independently and can arrive out of order. Sort by
message.timestampif order matters.
Headers#
| Header | Value |
|---|---|
Content-Type | application/json |
X-Wbiztool-Event | message_received or message_sent |
X-Wbiztool-Timestamp | When the webhook was sent, in ISO 8601 UTC. Same as timestamp in the body. |
X-Wbiztool-Webhook-Id | The listener's ID. Same as webhook_id in the body. |
X-Wbiztool-Signature | sha256= followed by the signature. Sent whenever the listener has a secret, which is always the case when a URL is set. |
Payload#
{
"event": "message_received",
"timestamp": "2026-09-16T10:31:12.482913+00:00",
"webhook_id": 42,
"whatsapp_client_id": "678",
"whatsapp_phone": "919812345678",
"message": {
"id": "[email protected]_3EB0C1A2B3D4E5F60718",
"type": "chat",
"content": "Hi, is my order #4821 out for delivery?",
"from": "919876543210",
"from_name": "Aman",
"to": "919812345678",
"timestamp": "2026-09-16T10:29:58+00:00",
"whatsapp_timestamp": 1789554598,
"direction": "incoming",
"status": "pending",
"is_forwarded": false,
"forwarding_score": 0
},
"contact": {
"whatsapp_id": "[email protected]",
"phone": "919876543210",
"name": "Aman",
"is_group": false,
"is_business": false
},
"organisation": {
"id": "10314",
"name": "Acme Stores"
}
}{
"event": "message_sent",
"timestamp": "2026-09-16T10:33:40.117205+00:00",
"webhook_id": 42,
"whatsapp_client_id": "678",
"whatsapp_phone": "919812345678",
"message": {
"id": "[email protected]_3EB0F9E8D7C6B5A40312",
"type": "chat",
"content": "Yes, it will reach you today by 6 PM.",
"from": "919876543210",
"from_name": "Aman",
"to": "919812345678",
"timestamp": "2026-09-16T10:31:04+00:00",
"whatsapp_timestamp": 1789554664,
"direction": "outgoing",
"status": "pending",
"is_forwarded": false,
"forwarding_score": 0
},
"contact": {
"whatsapp_id": "[email protected]",
"phone": "919876543210",
"name": "Aman",
"is_group": false,
"is_business": false
},
"organisation": {
"id": "10314",
"name": "Acme Stores"
}
}{
"event": "message_received",
"timestamp": "2026-09-16T10:36:02.904311+00:00",
"webhook_id": 42,
"whatsapp_client_id": "678",
"whatsapp_phone": "919812345678",
"message": {
"id": "[email protected]_3EB0A7B6C5D4E3F20109",
"type": "image",
"content": "📸 Image",
"from": "919876543210",
"from_name": "Aman",
"to": "919812345678",
"timestamp": "2026-09-16T10:34:51+00:00",
"whatsapp_timestamp": 1789554891,
"direction": "incoming",
"status": "pending",
"is_forwarded": false,
"forwarding_score": 0,
"media": {
"filename": "",
"mimetype": "image/jpeg",
"size": 245760
}
},
"contact": {
"whatsapp_id": "[email protected]",
"phone": "919876543210",
"name": "Aman",
"is_group": false,
"is_business": false
},
"organisation": {
"id": "10314",
"name": "Acme Stores"
}
}{
"event": "message_received",
"timestamp": "2026-09-16T10:40:15.330187+00:00",
"webhook_id": 42,
"whatsapp_client_id": "678",
"whatsapp_phone": "919812345678",
"message": {
"id": "[email protected][email protected]",
"type": "chat",
"content": "Is the store open on Sunday?",
"from": "120363041234567890",
"from_name": "Acme Loyalty Club",
"to": "919812345678",
"timestamp": "2026-09-16T10:39:02+00:00",
"whatsapp_timestamp": 1789555142,
"direction": "incoming",
"status": "pending",
"is_forwarded": false,
"forwarding_score": 0
},
"contact": {
"whatsapp_id": "[email protected]",
"phone": "120363041234567890",
"name": "Acme Loyalty Club",
"is_group": true,
"is_business": false
},
"organisation": {
"id": "10314",
"name": "Acme Stores"
},
"group": {
"name": "Acme Loyalty Club"
}
}The numbers, IDs and names above are examples.
Top-level fields#
| Field | Type | Description |
|---|---|---|
event | string | message_received or message_sent. |
timestamp | string | When the webhook was sent (ISO 8601, UTC). |
webhook_id | integer | The listener's ID. |
whatsapp_client_id | string | ID of your WhatsApp number, as shown on WhatsApp settings. |
whatsapp_phone | string | Your WhatsApp number. |
message | object | The message. See below. |
contact | object | The person or group the conversation is with. See below. |
organisation | object | id (string) and name of your workspace. |
group | object | Only for group chats: name, the group's name. |
message fields#
| Field | Type | Description |
|---|---|---|
id | string | WhatsApp's ID for the message. Use it to ignore duplicates. |
type | string | chat for text. Otherwise WhatsApp's type, such as image, video, audio, ptt (voice note), document, sticker or location. |
content | string | The text for chat messages. For media, a label instead: 📸 Image, 🎥 Video, 🎵 Audio, 🎤 Voice Message, 😊 Sticker, Document: followed by the document's text (Document when there's no text), or the type name in title case for anything else, such as Location. Captions aren't included. |
from | string | Always the contact's number (or the group's ID), in both directions. |
from_name | string | The contact's or group's name. Can be empty. |
to | string | Always your WhatsApp number, in both directions. |
timestamp | string | When the message was sent on WhatsApp (ISO 8601, UTC). |
whatsapp_timestamp | integer | The same time as a Unix timestamp in seconds. |
direction | string | incoming or outgoing. Use this, not from and to, to tell the direction. |
status | string | Currently always pending. Don't rely on it for delivery or read status. |
is_forwarded | boolean | Whether the message was forwarded. |
forwarding_score | integer | How many times it was forwarded. |
media | object | For media messages when details are available: filename, mimetype and size in bytes. The file itself isn't included. |
quoted_message_id | string | Only when the message replies to another message. |
contact fields#
| Field | Type | Description |
|---|---|---|
whatsapp_id | string | The WhatsApp ID, such as [email protected] for a person or …@g.us for a group. |
phone | string | The number without +, or the group's ID for groups. |
name | string | The name Wbiztool has for the contact, or the group name. Can be empty. |
is_group | boolean | true for group chats. |
is_business | boolean | true for WhatsApp Business accounts, when known. |
Verify the signature#
Each request is signed with your listener's secret using HMAC-SHA256. The signature is calculated over the raw request body exactly as received, and sent as sha256= plus the lowercase hex digest in X-Wbiztool-Signature.
Always compute the signature from the raw bytes before parsing the JSON. Parsing and re-encoding the body changes it (for example, non-English characters and emoji arrive escaped as \uXXXX), and the signature won't match.
// Express: keep the raw body for this route
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WBIZTOOL_WEBHOOK_SECRET;
app.post("/wbiztool/webhook", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const received = req.get("X-Wbiztool-Signature") || "";
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).send("Invalid signature");
const data = JSON.parse(req.body.toString("utf8"));
if (data.event === "message_received") {
console.log(`New message from ${data.contact.phone}: ${data.message.content}`);
}
res.sendStatus(200); // reply quickly; do slow work in the background
});
app.listen(3000);# Flask
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WBIZTOOL_WEBHOOK_SECRET"].encode()
@app.post("/wbiztool/webhook")
def wbiztool_webhook():
raw_body = request.get_data() # raw bytes, before parsing
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
received = request.headers.get("X-Wbiztool-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
data = json.loads(raw_body)
if data["event"] == "message_received":
print(f"New message from {data['contact']['phone']}: {data['message']['content']}")
return "", 200<?php
$secret = getenv('WBIZTOOL_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
$received = $_SERVER['HTTP_X_WBIZTOOL_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit('Invalid signature');
}
$data = json_decode($rawBody, true);
if ($data['event'] === 'message_received') {
error_log('New message from ' . $data['contact']['phone'] . ': ' . $data['message']['content']);
}
http_response_code(200);The signature doesn't cover the timestamp header, so it doesn't protect against a request being replayed. If that matters, store each message.id you've processed and ignore repeats.
Troubleshooting#
| Message or problem | What to do |
|---|---|
| Unibox Add-on Required when you click Add New Listener | Your workspace doesn't have the Unibox add-on. Buy it under Available Add-ons on Billing & Plans inside the dashboard. |
You have reached your unibox numbers limit | Delete a listener you no longer need, or increase the add-on quantity. |
| No available WhatsApp numbers | Connect another number, or it's already a listener. |
This WhatsApp number is already a listener | Edit the existing card instead. |
Invalid WhatsApp client | The number disconnected. Reconnect it on WhatsApp settings and reload the page. |
Error mentioning value too long when saving | The webhook URL is longer than 100 characters. Use a shorter URL. |
| No webhooks arrive | Check the listener is Active, the number is connected, the event type is ticked, and your URL is public https with a valid certificate. Messages are only sent after the next sync, a few minutes later. |
| Some webhooks are missing | Your server returned something other than 200, took longer than 8 seconds or was unreachable. Failed deliveries aren't retried. |
| Signature doesn't match | Use the raw body, not re-encoded JSON, and the current secret. Regenerating the secret, or connecting Zapier, replaces it. |
| Last Activity: says Never | The number hasn't been checked yet. It must be connected and not busy sending messages. |
