Skip to content

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 ​

  1. You add a webhook URL on the instance detail page in the ChatBreez dashboard.
  2. A WhatsApp user sends a message to your connected instance.
  3. ChatBreez sends a POST request to your webhook URL with a JSON payload describing the event. The request includes a signed Authorization header so you can verify it came from ChatBreez.
  4. 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 application

Your 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.

  1. Sign in to chatbreez.com.
  2. Open the dashboard.
  3. Go to your WhatsApp instances.
  4. Select the instance you want to configure.
  5. On the instance detail page, enter your webhook URL.
  6. Save the webhook.

Chatbreez webhook configuration

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/chatbreez

2. Read the request body ​

Each webhook request has a JSON body with three top-level fields:

FieldTypeDescription
eventstringThe event type (for example, messages.upsert).
instancestringThe ChatBreez instance name that received the message.
dataobjectEvent-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:

EventDescription
messages.upsertA 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:

FieldDescription
data.key.remoteJidThe 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.fromMetrue if the message was sent by your instance; false if it was received from a contact.
data.key.idUnique message ID. Use this for idempotency and deduplication.
data.messageMessage 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."
    }
  }
}
FieldDescription
conversationThe 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"
      }
    }
  }
}
FieldDescription
locationMessage.degreesLatitudeLatitude as a decimal number.
locationMessage.degreesLongitudeLongitude as a decimal number.
locationMessage.nameLocation name, if the contact added one (may be empty).
locationMessage.addressLocation 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"
      }
    }
  }
}
FieldDescription
contactMessage.displayNameThe contact's display name.
contactMessage.vcardFull 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
      }
    }
  }
}
FieldDescription
pollCreationMessage.nameThe poll question.
pollCreationMessage.optionsArray of option objects, each with an optionName string.
pollCreationMessage.selectableOptionsCountHow 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"]
        }
      }
    }
  }
}
FieldDescription
pollUpdateMessage.pollCreationMessageKey.idThe data.key.id of the original poll creation message. Use this to match the vote to the poll.
pollUpdateMessage.vote.selectedOptionsArray 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
      }
    }
  }
}
FieldDescription
audioMessage.urlURL to download the audio file.
audioMessage.mimetypeMIME type of the audio (e.g. audio/ogg; codecs=opus for voice notes, audio/mpeg for MP3).
audioMessage.secondsDuration of the audio in seconds.
audioMessage.ptttrue 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:

KeyMedia type
imageMessageJPEG, PNG, WebP, GIF
videoMessageMP4, MOV
documentMessagePDF, DOCX, XLSX, and other files

All three share the same core fields:

FieldDescription
*.urlURL to download the file.
*.mimetypeMIME type of the file.
*.captionOptional caption the contact added (may be absent).
*.fileNameOriginal 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 ​

  1. ChatBreez signs a JWT using your API key as the secret
  2. The JWT is attached as Authorization: Bearer <token> on every delivery
  3. Your server receives the request, extracts the token, and verifies it using the same API key
  4. If verification passes, process the event. If it fails, return 401 and 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}), 200

Your 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:

  1. Start your webhook receiver locally (for example, on port 3000).
  2. Start a tunnel:
bash
ngrok http 3000
  1. Copy the generated HTTPS URL (for example, https://abc123.ngrok-free.app).
  2. In the ChatBreez dashboard, set your webhook URL to:
http
POST https://abc123.ngrok-free.app/webhooks/chatbreez
  1. 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: Bearer JWT check before touching the payload. An unverified endpoint accepts fake events from anyone who knows the URL.
  • Respond fast. Return 200 OK within 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.id before processing and skip duplicates.
  • Ignore your own messages. Check data.key.fromMe so you do not react to messages sent by your instance.
  • Use HTTPS in production. Webhook URLs must use https://.
  • Keep logs. Log event, instance, and data.key.id to debug missed or duplicate deliveries.

Troubleshooting ​

IssueWhat to check
Webhook does not receive eventsConfirm the instance is connected and the webhook URL is saved on the instance detail page.
Request fails when saving the webhookMake sure you have generated an API key in the dashboard first.
Events arrive more than onceMake your handler idempotent by storing the message ID before processing.
Webhook times outReturn 200 OK quickly, then process long-running tasks in the background.
Local testing does not workUse a tunnel such as ngrok and paste the public HTTPS URL into the dashboard.
Signature verification fails after key rotationRegenerating your API key automatically re-syncs all webhooks. Update CHATBREEZ_API_KEY in your server environment to match.