Appearance
Set Up Webhooks
Webhooks let your application receive WhatsApp events from a ChatBreez instance in real time. Instead of polling the API for new messages, ChatBreez sends an HTTP POST request to your server whenever a matching event occurs.
This guide covers how to register a webhook from the dashboard and how to build an endpoint that listens for and processes those events.
How It Works
- You add a webhook URL on the instance detail page in the ChatBreez dashboard.
- A WhatsApp user sends a message to your connected instance.
- ChatBreez sends a
POSTrequest to your webhook URL with a JSON payload describing the event. The request includes a signedAuthorizationheader so you can verify it came from ChatBreez. - Your application verifies the signature, rejects the request if it fails, reads the payload, handles the event, and responds with
200 OK.
WhatsApp user → ChatBreez instance → POST https://your-app.com/webhooks/chatbreez → Your applicationYour webhook URL must be publicly reachable over HTTPS. ChatBreez cannot deliver events to localhost unless you expose it with a tunnel (for example, ngrok or Cloudflare Tunnel).
Set Up From The Dashboard
Configure webhooks only from the dashboard. There is no public API for registering webhook URLs.
Before you begin, make sure you have generated an API key in the API Keys section of your dashboard. ChatBreez uses your API key to sign webhook deliveries — it is required before a webhook can be saved.
- Sign in to chatbreez.com.
- Open the dashboard.
- Go to your WhatsApp instances.
- Select the instance you want to configure.
- On the instance detail page, enter your webhook URL.
- Save the webhook.

After saving, ChatBreez starts sending incoming message events to that URL for the selected instance. Each delivery will include a signed Authorization header you can use to verify the request came from ChatBreez.
Listen To Webhook Events
To integrate ChatBreez with your application, create an HTTP endpoint that accepts POST requests, reads the JSON body, and acts on the event.
1. Create a webhook endpoint
Add a route in your application that listens for POST requests. Choose any path you control, as long as it matches the URL you saved in the dashboard.
Example endpoint:
http
POST https://example.com/webhooks/chatbreez2. Read the request body
Each webhook request has a JSON body with three top-level fields:
| Field | Type | Description |
|---|---|---|
event | string | The event type (for example, messages.upsert). |
instance | string | The ChatBreez instance name that received the message. |
data | object | Event-specific payload. For message events, this contains the message details. |
3. Handle the event
Use the event field to decide what your application should do. ChatBreez currently sends incoming message events for configured instances.
Supported event:
| Event | Description |
|---|---|
messages.upsert | A new message was received or updated on the instance. |
Inspect data for the message content, sender, and message ID. The data.key object is always present. The shape of data.message depends on the type of message received — see Message types below.
Example payload:
json
{
"event": "messages.upsert",
"instance": "my-instance",
"data": {
"key": {
"remoteJid": "123456789012345@lid",
"fromMe": false,
"id": "MESSAGE_ID"
},
"message": {
"conversation": "Hello"
}
}
}Field reference for messages.upsert:
| Field | Description |
|---|---|
data.key.remoteJid | The sender address. It may end in @s.whatsapp.net or @lid. When replying, you can use the contact's WhatsApp phone number with country code as before, or use this full address as the recipient. If the address ends in @lid, send it exactly as received; do not remove the suffix or format it. |
data.key.fromMe | true if the message was sent by your instance; false if it was received from a contact. |
data.key.id | Unique message ID. Use this for idempotency and deduplication. |
data.message | Message content object. The key inside this object identifies the message type — see Message types. |
Message types
The data.message object contains exactly one key that identifies the message type. Detect the type by checking which key is present — there is no separate type field.
Text
The most common message type. The message body is a plain string.
json
{
"data": {
"message": {
"conversation": "Hello, I need help with my order."
}
}
}| Field | Description |
|---|---|
conversation | The plain-text message body. |
Location
The contact shared a location pin.
json
{
"data": {
"message": {
"locationMessage": {
"degreesLatitude": -23.5505,
"degreesLongitude": -46.6333,
"name": "São Paulo",
"address": "São Paulo, Brazil"
}
}
}
}| Field | Description |
|---|---|
locationMessage.degreesLatitude | Latitude as a decimal number. |
locationMessage.degreesLongitude | Longitude as a decimal number. |
locationMessage.name | Location name, if the contact added one (may be empty). |
locationMessage.address | Location address, if the contact added one (may be empty). |
Contact card
The contact shared one or more vCard contact cards.
json
{
"data": {
"message": {
"contactMessage": {
"displayName": "Jane Smith",
"vcard": "BEGIN:VCARD\nVERSION:3.0\nFN:Jane Smith\nTEL;type=CELL:+254123456789\nEND:VCARD"
}
}
}
}| Field | Description |
|---|---|
contactMessage.displayName | The contact's display name. |
contactMessage.vcard | Full vCard string. Parse it with a vCard library to extract phone numbers, emails, and other fields. |
Parsing vCards
Use a library such as vcf (Node.js) or vobject (Python) to parse the vcard string into structured fields rather than splitting it manually.
Poll — creation
A contact sent a poll to your instance. This fires when the poll first arrives.
json
{
"data": {
"message": {
"pollCreationMessage": {
"name": "What is your favourite colour?",
"options": [
{ "optionName": "Red" },
{ "optionName": "Blue" },
{ "optionName": "Green" }
],
"selectableOptionsCount": 1
}
}
}
}| Field | Description |
|---|---|
pollCreationMessage.name | The poll question. |
pollCreationMessage.options | Array of option objects, each with an optionName string. |
pollCreationMessage.selectableOptionsCount | How many options the recipient may select. |
Poll — vote update
A contact voted on a poll. This fires each time someone submits or changes their vote. The pollCreationMessageKey.id links the vote back to the original poll message.
json
{
"data": {
"message": {
"pollUpdateMessage": {
"pollCreationMessageKey": {
"id": "ORIGINAL_POLL_MESSAGE_ID"
},
"vote": {
"selectedOptions": ["Red"]
}
}
}
}
}| Field | Description |
|---|---|
pollUpdateMessage.pollCreationMessageKey.id | The data.key.id of the original poll creation message. Use this to match the vote to the poll. |
pollUpdateMessage.vote.selectedOptions | Array of option name strings that the contact selected. Empty array means the contact cleared their vote. |
Tracking votes
Store each vote keyed by { pollMessageId, senderJid } and overwrite on update — WhatsApp allows contacts to change their vote, so you will receive a new pollUpdateMessage each time. An empty selectedOptions means the contact deselected all options.
Audio
The contact sent an audio file or a voice note (PTT).
json
{
"data": {
"message": {
"audioMessage": {
"url": "https://mmg.whatsapp.net/...",
"mimetype": "audio/ogg; codecs=opus",
"seconds": 12,
"ptt": true
}
}
}
}| Field | Description |
|---|---|
audioMessage.url | URL to download the audio file. |
audioMessage.mimetype | MIME type of the audio (e.g. audio/ogg; codecs=opus for voice notes, audio/mpeg for MP3). |
audioMessage.seconds | Duration of the audio in seconds. |
audioMessage.ptt | true if the message is a push-to-talk voice note; false for a regular audio file. |
Media (image, video, document)
The contact sent an image, video, or document.
json
{
"data": {
"message": {
"imageMessage": {
"url": "https://mmg.whatsapp.net/...",
"mimetype": "image/jpeg",
"caption": "Check this out"
}
}
}
}The key changes depending on the media type:
| Key | Media type |
|---|---|
imageMessage | JPEG, PNG, WebP, GIF |
videoMessage | MP4, MOV |
documentMessage | PDF, DOCX, XLSX, and other files |
All three share the same core fields:
| Field | Description |
|---|---|
*.url | URL to download the file. |
*.mimetype | MIME type of the file. |
*.caption | Optional caption the contact added (may be absent). |
*.fileName | Original file name — present on documentMessage, may be absent on image/video. |
Detecting the message type in code
Check which key exists inside data.message to determine the message type:
javascript
const msg = data?.message;
if (msg?.conversation) {
// plain text
const text = msg.conversation;
} else if (msg?.locationMessage) {
const { degreesLatitude, degreesLongitude, name, address } = msg.locationMessage;
} else if (msg?.contactMessage) {
const { displayName, vcard } = msg.contactMessage;
} else if (msg?.pollCreationMessage) {
const { name, options, selectableOptionsCount } = msg.pollCreationMessage;
} else if (msg?.pollUpdateMessage) {
const pollId = msg.pollUpdateMessage.pollCreationMessageKey?.id;
const selected = msg.pollUpdateMessage.vote?.selectedOptions ?? [];
} else if (msg?.audioMessage) {
const { url, mimetype, seconds, ptt } = msg.audioMessage;
} else if (msg?.imageMessage) {
const { url, mimetype, caption } = msg.imageMessage;
} else if (msg?.videoMessage) {
const { url, mimetype, caption } = msg.videoMessage;
} else if (msg?.documentMessage) {
const { url, mimetype, fileName } = msg.documentMessage;
}python
msg = data.get("message") or {}
if "conversation" in msg:
text = msg["conversation"]
elif "locationMessage" in msg:
loc = msg["locationMessage"]
lat, lng = loc["degreesLatitude"], loc["degreesLongitude"]
elif "contactMessage" in msg:
contact = msg["contactMessage"]
display_name = contact["displayName"]
vcard = contact["vcard"]
elif "pollCreationMessage" in msg:
poll = msg["pollCreationMessage"]
question = poll["name"]
options = [o["optionName"] for o in poll["options"]]
elif "pollUpdateMessage" in msg:
update = msg["pollUpdateMessage"]
poll_id = update["pollCreationMessageKey"]["id"]
selected = update["vote"]["selectedOptions"]
elif "audioMessage" in msg:
audio = msg["audioMessage"]
is_voice_note = audio.get("ptt", False)
elif "imageMessage" in msg:
image = msg["imageMessage"]
elif "videoMessage" in msg:
video = msg["videoMessage"]
elif "documentMessage" in msg:
doc = msg["documentMessage"]php
$msg = $data['message'] ?? [];
if (isset($msg['conversation'])) {
$text = $msg['conversation'];
} elseif (isset($msg['locationMessage'])) {
$lat = $msg['locationMessage']['degreesLatitude'];
$lng = $msg['locationMessage']['degreesLongitude'];
} elseif (isset($msg['contactMessage'])) {
$displayName = $msg['contactMessage']['displayName'];
$vcard = $msg['contactMessage']['vcard'];
} elseif (isset($msg['pollCreationMessage'])) {
$question = $msg['pollCreationMessage']['name'];
$options = array_column($msg['pollCreationMessage']['options'], 'optionName');
} elseif (isset($msg['pollUpdateMessage'])) {
$pollId = $msg['pollUpdateMessage']['pollCreationMessageKey']['id'];
$selected = $msg['pollUpdateMessage']['vote']['selectedOptions'];
} elseif (isset($msg['audioMessage'])) {
$isPtt = $msg['audioMessage']['ptt'] ?? false;
} elseif (isset($msg['imageMessage'])) {
$url = $msg['imageMessage']['url'];
} elseif (isset($msg['videoMessage'])) {
$url = $msg['videoMessage']['url'];
} elseif (isset($msg['documentMessage'])) {
$url = $msg['documentMessage']['url'];
$fileName = $msg['documentMessage']['fileName'] ?? null;
}Unknown message types
WhatsApp supports many message types beyond those listed here (stickers, reactions, status updates, etc.). Always include a fallback for unrecognised keys so your handler does not crash on unexpected payloads.
4. Respond quickly
Return a 2xx response as soon as your server accepts the event. ChatBreez treats non-success responses as delivery failures.
Do not run slow work (database writes, third-party API calls, AI processing) before responding. Acknowledge the webhook first, then process the event in the background.
json
{ "success": true }A 200 OK with any small JSON body is enough.
Verifying Webhook Requests
Every webhook delivery from ChatBreez includes an Authorization header with a signed JWT token:
http
Authorization: Bearer <token>ChatBreez signs this token using your API key — the same key you use to send messages via X-API-Key. Verifying the token confirms two things: the request came from ChatBreez, and the payload was not tampered with in transit.
You must verify this header. Without verification, anyone who knows your endpoint URL can POST fake events to it and your application will process them as real messages.
How it works
- ChatBreez signs a JWT using your API key as the secret
- The JWT is attached as
Authorization: Bearer <token>on every delivery - Your server receives the request, extracts the token, and verifies it using the same API key
- If verification passes, process the event. If it fails, return
401and discard it.
The token expires every 10 minutes. ChatBreez generates a fresh one for each delivery so you never need to manage token rotation yourself.
How to verify
Store your ChatBreez API key as CHATBREEZ_API_KEY in your server's environment variables. Use any standard JWT library to verify the token on every incoming request.
javascript
const express = require('express');
const jwt = require('jsonwebtoken'); // npm install jsonwebtoken
const app = express();
app.use(express.json());
app.post('/webhooks/chatbreez', (req, res) => {
// Step 1: Reject the request if the Authorization header is missing
const authHeader = req.headers['authorization'];
if (!authHeader) {
return res.status(401).json({ error: 'Missing Authorization header' });
}
// Step 2: Verify the JWT using your API key
const apiKey = process.env.CHATBREEZ_API_KEY;
const token = authHeader.replace('Bearer ', '');
try {
jwt.verify(token, apiKey);
} catch {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
// Step 3: Signature verified — now safe to process the event
const { event, instance, data } = req.body;
// ...
res.status(200).json({ success: true });
});php
use Illuminate\Http\Request;
use Firebase\JWT\JWT; // composer require firebase/php-jwt
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/chatbreez', function (Request $request) {
// Step 1: Reject the request if the Authorization header is missing
$authHeader = $request->header('Authorization');
if (!$authHeader) {
return response()->json(['error' => 'Missing Authorization header'], 401);
}
// Step 2: Verify the JWT using your API key
$apiKey = env('CHATBREEZ_API_KEY');
$token = str_replace('Bearer ', '', $authHeader);
try {
JWT::decode($token, new Key($apiKey, 'HS256'));
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid webhook signature'], 401);
}
// Step 3: Signature verified — now safe to process the event
$event = $request->input('event');
$instance = $request->input('instance');
$data = $request->input('data', []);
// ...
return response()->json(['success' => true]);
});python
import os
from flask import Flask, request, jsonify
import jwt # pip install PyJWT
app = Flask(__name__)
@app.post("/webhooks/chatbreez")
def chatbreez_webhook():
# Step 1: Reject the request if the Authorization header is missing
auth_header = request.headers.get("Authorization", "")
if not auth_header:
return jsonify({"error": "Missing Authorization header"}), 401
# Step 2: Verify the JWT using your API key
api_key = os.environ.get("CHATBREEZ_API_KEY")
token = auth_header.replace("Bearer ", "")
try:
jwt.decode(token, api_key, algorithms=["HS256"])
except jwt.PyJWTError:
return jsonify({"error": "Invalid webhook signature"}), 401
# Step 3: Signature verified — now safe to process the event
payload = request.get_json(silent=True) or {}
event = payload.get("event")
instance = payload.get("instance")
data = payload.get("data") or {}
# ...
return jsonify({"success": True}), 200Your API key is your signing secret
The same key you use in the X-API-Key header to send messages is what ChatBreez uses to sign webhook deliveries. You do not need a separate secret — find it in the API Keys section of your dashboard.
Rotate your API key carefully
If you regenerate your API key in the dashboard, ChatBreez automatically re-registers all your webhook instances with the new key. Update the CHATBREEZ_API_KEY environment variable in your webhook server at the same time, otherwise the signature check will fail and your server will reject deliveries.
Integration Examples
These examples show a complete flow: receive the webhook, read the event, handle a new message, and return 200 OK.
javascript
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/chatbreez', (req, res) => {
// Respond immediately — process in the background
res.status(200).json({ success: true });
const { event, instance, data } = req.body;
if ((event === 'messages.upsert' || event === 'MESSAGES_UPSERT') && !data?.key?.fromMe) {
const remoteJid = data?.key?.remoteJid;
// Use sender as recipient: @lid stays whole; phone JIDs become numbers. Or use remoteJid unchanged.
const sender = remoteJid?.endsWith('@s.whatsapp.net')
? remoteJid.split('@')[0]
: remoteJid;
const messageId = data?.key?.id;
const msg = data?.message;
if (msg?.conversation) {
console.log(`[${instance}] Text from ${sender}: ${msg.conversation}`);
} else if (msg?.locationMessage) {
const { degreesLatitude, degreesLongitude, name } = msg.locationMessage;
console.log(`[${instance}] Location from ${sender}: ${name} (${degreesLatitude}, ${degreesLongitude})`);
} else if (msg?.contactMessage) {
console.log(`[${instance}] Contact from ${sender}: ${msg.contactMessage.displayName}`);
} else if (msg?.pollCreationMessage) {
console.log(`[${instance}] Poll from ${sender}: "${msg.pollCreationMessage.name}"`);
} else if (msg?.pollUpdateMessage) {
const pollId = msg.pollUpdateMessage.pollCreationMessageKey?.id;
const selected = msg.pollUpdateMessage.vote?.selectedOptions ?? [];
console.log(`[${instance}] Vote from ${sender} on poll ${pollId}:`, selected);
} else if (msg?.audioMessage) {
const label = msg.audioMessage.ptt ? 'Voice note' : 'Audio';
console.log(`[${instance}] ${label} from ${sender} (${msg.audioMessage.seconds}s)`);
} else if (msg?.imageMessage || msg?.videoMessage || msg?.documentMessage) {
const media = msg.imageMessage ?? msg.videoMessage ?? msg.documentMessage;
console.log(`[${instance}] Media from ${sender}: ${media.mimetype}`);
}
}
});
app.listen(3000, () => {
console.log('Listening for ChatBreez webhooks on port 3000');
});php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/chatbreez', function (Request $request) {
$event = $request->input('event');
$instance = $request->input('instance');
$data = $request->input('data', []);
$key = $data['key'] ?? [];
$msg = $data['message'] ?? [];
if (in_array($event, ['messages.upsert', 'MESSAGES_UPSERT'], true) && !($key['fromMe'] ?? false)) {
$remoteJid = $key['remoteJid'] ?? null;
// Use sender as recipient: @lid stays whole; phone JIDs become numbers. Or use remoteJid unchanged.
$sender = is_string($remoteJid) && str_ends_with($remoteJid, '@s.whatsapp.net')
? explode('@', $remoteJid, 2)[0]
: $remoteJid;
if (isset($msg['conversation'])) {
Log::info("[$instance] Text from $sender: " . $msg['conversation']);
} elseif (isset($msg['locationMessage'])) {
$loc = $msg['locationMessage'];
Log::info("[$instance] Location from $sender: {$loc['name']} ({$loc['degreesLatitude']}, {$loc['degreesLongitude']})");
} elseif (isset($msg['contactMessage'])) {
Log::info("[$instance] Contact from $sender: " . $msg['contactMessage']['displayName']);
} elseif (isset($msg['pollCreationMessage'])) {
Log::info("[$instance] Poll from $sender: " . $msg['pollCreationMessage']['name']);
} elseif (isset($msg['pollUpdateMessage'])) {
$pollId = $msg['pollUpdateMessage']['pollCreationMessageKey']['id'] ?? null;
$selected = $msg['pollUpdateMessage']['vote']['selectedOptions'] ?? [];
Log::info("[$instance] Vote from $sender on poll $pollId", $selected);
} elseif (isset($msg['audioMessage'])) {
$label = ($msg['audioMessage']['ptt'] ?? false) ? 'Voice note' : 'Audio';
Log::info("[$instance] $label from $sender ({$msg['audioMessage']['seconds']}s)");
} elseif (isset($msg['imageMessage']) || isset($msg['videoMessage']) || isset($msg['documentMessage'])) {
$media = $msg['imageMessage'] ?? $msg['videoMessage'] ?? $msg['documentMessage'];
Log::info("[$instance] Media from $sender: " . $media['mimetype']);
}
}
return response()->json(['success' => true]);
});python
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/webhooks/chatbreez")
def chatbreez_webhook():
payload = request.get_json(silent=True) or {}
event = payload.get("event")
instance = payload.get("instance")
data = payload.get("data") or {}
key = data.get("key") or {}
msg = data.get("message") or {}
if event in ("messages.upsert", "MESSAGES_UPSERT") and not key.get("fromMe"):
remote_jid = key.get("remoteJid")
# Use sender as recipient: @lid stays whole; phone JIDs become numbers. Or use remote_jid unchanged.
sender = remote_jid.split("@", 1)[0] if remote_jid and remote_jid.endswith("@s.whatsapp.net") else remote_jid
if "conversation" in msg:
print(f"[{instance}] Text from {sender}: {msg['conversation']}")
elif "locationMessage" in msg:
loc = msg["locationMessage"]
print(f"[{instance}] Location from {sender}: {loc.get('name')} ({loc['degreesLatitude']}, {loc['degreesLongitude']})")
elif "contactMessage" in msg:
print(f"[{instance}] Contact from {sender}: {msg['contactMessage']['displayName']}")
elif "pollCreationMessage" in msg:
print(f"[{instance}] Poll from {sender}: \"{msg['pollCreationMessage']['name']}\"")
elif "pollUpdateMessage" in msg:
poll_id = msg["pollUpdateMessage"]["pollCreationMessageKey"]["id"]
selected = msg["pollUpdateMessage"]["vote"]["selectedOptions"]
print(f"[{instance}] Vote from {sender} on poll {poll_id}: {selected}")
elif "audioMessage" in msg:
label = "Voice note" if msg["audioMessage"].get("ptt") else "Audio"
print(f"[{instance}] {label} from {sender} ({msg['audioMessage']['seconds']}s)")
elif any(k in msg for k in ("imageMessage", "videoMessage", "documentMessage")):
media = msg.get("imageMessage") or msg.get("videoMessage") or msg.get("documentMessage")
print(f"[{instance}] Media from {sender}: {media['mimetype']}")
return jsonify({"success": True}), 200
if __name__ == "__main__":
app.run(port=3000)Test Locally
During development, your app usually runs on localhost, which ChatBreez cannot reach directly. Use a tunnel to expose your local server:
- Start your webhook receiver locally (for example, on port
3000). - Start a tunnel:
bash
ngrok http 3000- Copy the generated HTTPS URL (for example,
https://abc123.ngrok-free.app). - In the ChatBreez dashboard, set your webhook URL to:
http
POST https://abc123.ngrok-free.app/webhooks/chatbreez- Send a WhatsApp message to your connected instance and confirm your server logs the event.
Best Practices
- Always verify the signature. Reject any request missing or failing the
Authorization: BearerJWT check before touching the payload. An unverified endpoint accepts fake events from anyone who knows the URL. - Respond fast. Return
200 OKwithin a few seconds. Move heavy processing to a queue or background job. - Make handlers idempotent. The same event may be delivered more than once. Store
data.key.idbefore processing and skip duplicates. - Ignore your own messages. Check
data.key.fromMeso you do not react to messages sent by your instance. - Use HTTPS in production. Webhook URLs must use
https://. - Keep logs. Log
event,instance, anddata.key.idto debug missed or duplicate deliveries.
Troubleshooting
| Issue | What to check |
|---|---|
| Webhook does not receive events | Confirm the instance is connected and the webhook URL is saved on the instance detail page. |
| Request fails when saving the webhook | Make sure you have generated an API key in the dashboard first. |
| Events arrive more than once | Make your handler idempotent by storing the message ID before processing. |
| Webhook times out | Return 200 OK quickly, then process long-running tasks in the background. |
| Local testing does not work | Use a tunnel such as ngrok and paste the public HTTPS URL into the dashboard. |
| Signature verification fails after key rotation | Regenerating your API key automatically re-syncs all webhooks. Update CHATBREEZ_API_KEY in your server environment to match. |
