Section 01
Before you start
Your workspace needs an active Growth, Pro or Enterprise subscription. The plan decides which endpoints a key can call, how many keys you can hold and how fast you can call.
| Plan | What the API can do | API keys | Requests / min, per key |
|---|---|---|---|
| Growth | Send messages, read templates, read and write contacts | 1 | 60 |
| Pro | Everything in Growth, plus read conversations, read and create broadcasts, list campaigns and add or remove contacts | 2 | 120 |
| Enterprise | Everything, including stats and webhook management | Unlimited | 5,000 |
Other requirements
- Who can create keys: a workspace member whose role has the Manage API keys permission. Admins have it by default.
- Workspace state: the subscription must be active, or on a trial that has not ended. An overdue, suspended or cancelled workspace is refused with
402.
Create a key
- 1Open Settings → API Keys in Bot Listener.
- 2Click Create API key, give it a name (3–100 characters) and tick the scopes it needs. Scopes your plan does not include are shown locked. Optionally set an expiry date.
- 3Copy the key immediately. It is shown once only and cannot be recovered. The list afterwards shows only its first 16 characters (for example
bl_live_ffbcd6e7).
Good to know
- Key names must be unique among your active keys. Revoking a key frees its name and its slot.
- There is no key rotation. To rotate, create a new key, switch your integration to it, then revoke the old one. On Growth (1 key) you must revoke first, so plan a short switch-over.
- Revoking takes effect on the very next request. An expired key is refused and deactivated automatically.
- Give each integration its own key with only the scopes it needs.
- If you downgrade, existing keys keep working, but only for the scopes the new plan includes.
Section 02
Base URL and authentication
All endpoints live under one base URL:
https://api.botlistener.com/api/external/v1Send your key in either header; both are accepted:
Authorization: Bearer bl_live_<64 hex characters>
X-API-Key: bl_live_<64 hex characters>A key is always 72 characters: bl_live_ followed by 64 lowercase hex characters. Anything else is refused with 401 Invalid API key format.
The examples in this document use two shell variables, so they paste straight into a terminal:
export BL_BASE="https://api.botlistener.com/api/external/v1"
export BL_KEY="bl_live_..." # from Settings → API KeysSection 03
Conventions
Requests
- Send JSON bodies with
Content-Type: application/json. A body that is not valid JSON returns400 INVALID_JSON. - A missing required field, or a field of the wrong type, returns
422 VALIDATION_ERROR, witherror.detailsnaming each problem. - Optional fields may be sent as
null; that is the same as leaving them out. - IDs are opaque strings (for example
cmu5bcfwc00ow6az5oh94b0qv). Don't parse them.
Responses
Every response uses the same envelope.
{ "success": true, "data": { } }{
"success": true,
"data": [ ],
"meta": { "page": 1, "limit": 20, "total": 21, "hasMore": true }
}{ "success": false, "error": { "code": "CONTACT_NOT_FOUND", "message": "Contact not found" } }Pagination
List endpoints take page (default 1) and limit (default 20, maximum 100; a larger value returns 100). Keep requesting the next page while meta.hasMore is true.
Timestamps
ISO-8601 in UTC, for example 2026-09-21T06:01:46.852Z.
Empty fields are omitted
When a value is not set (for example a contact with no email), the key is absent from the JSON rather than null. Test for presence, not for null.
Phone numbers
Phone numbers are returned in E.164 format: +, country code and number, no spaces (+919812300001).
+ in query strings. An unencoded + means a space, so ?phoneNumber=+919812300001 matches nothing. Send ?phoneNumber=%2B919812300001. Most HTTP clients do this for you when you pass query parameters as a map.Enum values
Filters such as ?status= must match exactly and in uppercase: OPEN, not open. Message type, status and direction are returned in lowercase.
| Enum | Values |
|---|---|
| Conversation status | OPEN RESOLVED SNOOZED SPAM PENDING |
| Broadcast status | DRAFT SCHEDULED RUNNING PAUSED COMPLETED CANCELLED FAILED |
| Campaign status | DRAFT SCHEDULED ACTIVE PAUSED COMPLETED STOPPED ARCHIVED |
| Template status | DRAFT PENDING APPROVED REJECTED PAUSED DISABLED |
| Template category | MARKETING UTILITY AUTHENTICATION |
| Message status (lowercase) | sent delivered read failed |
| Message type (lowercase) | text image video document audio template interactive location sticker reaction |
Tags
Tags are stored lowercased, with spaces turned into hyphens and duplicates removed: "Hot Lead" becomes hot-lead. The ?tags= filter uses the stored form, so filter with ?tags=hot-lead.
Section 04
Scopes
A key can call only the endpoints its scopes allow; anything else returns 403 API_PERMISSION_DENIED naming the missing scope. Each scope is also limited by your plan: a scope the plan lacks can't be added to a key (402 API_SCOPE_NOT_IN_PLAN), and calling it returns 402 API_PLAN_UPGRADE_REQUIRED.
| Scope | Grants | Plan |
|---|---|---|
messages:send | POST /messages | Growth+ |
templates:read | GET /templates | Growth+ |
contacts:read | GET /contacts, GET /contacts/{id} | Growth+ |
contacts:write | POST /contacts, PUT /contacts/{id}, DELETE /contacts/{id} | Growth+ |
conversations:read | GET /conversations, GET /conversations/{id}, GET /conversations/{id}/messages | Pro+ |
broadcasts:read | GET /broadcasts, GET /broadcasts/{id} | Pro+ |
broadcasts:write | POST /broadcasts, POST /broadcasts/{id}/send | Pro+ |
campaigns:read | GET /campaigns | Pro+ |
campaigns:write | POST /campaigns/{id}/enroll, POST /campaigns/{id}/exit | Pro+ |
analytics:read | GET /stats | Enterprise |
webhooks:manage | GET /webhooks, POST /webhooks, DELETE /webhooks/{id} | Enterprise |
contacts:write does not include contacts:read. Grant both if your integration reads back what it writes.Section 05
Rate limits
Limits are counted per API key, in one-minute windows aligned to the clock: Growth 60, Pro 120 and Enterprise 5,000 requests per minute. Requests refused with 401, or with a workspace 402 (inactive subscription or workspace), do not count.
Every authenticated response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix time (seconds) when the window resets |
Over the limit you get 429 TOO_MANY_REQUESTS with a Retry-After header in seconds. Wait that long before retrying.
Section 06
Errors
| HTTP | error.code | When |
|---|---|---|
| 400 | INVALID_JSON | The request body is not valid JSON |
| 401 | UNAUTHORIZED | Key missing, malformed, unknown, revoked or expired |
| 402 | API_ACCESS_REQUIRED | The workspace's plan does not include API access |
| 402 | API_PLAN_UPGRADE_REQUIRED | The plan does not include this endpoint's scope; the message names the plan needed |
| 402 | FEATURE_NOT_AVAILABLE | Campaign endpoints: the plan does not include Campaigns |
| 402 | SUBSCRIPTION_INACTIVE | The subscription is not active (including overdue) |
| 402 | TRIAL_EXPIRED | The trial has ended |
| 402 | WORKSPACE_INACTIVE | The workspace is suspended or deactivated |
| 403 | API_PERMISSION_DENIED | The key lacks the scope this endpoint needs |
| 404 | *_NOT_FOUND | The resource does not exist in this key's workspace |
| 409 | CONTACT_EXISTS | A contact with that phone number already exists |
| 422 | VALIDATION_ERROR and endpoint codes | Invalid input; codes are listed per endpoint below |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded |
| 500 | INTERNAL_ERROR | Unexpected server error |
How to handle them
- 401 / 403: fix the key or its scopes; retrying won't help.
- 402: a plan or billing issue on the workspace; retrying won't help.
- 429: wait for
Retry-After, then retry. - 5xx: retry with exponential backoff.
POST /messages or POST /broadcasts/{id}/send unless you know the first call failed, or the message may be sent twice.Section 07
Contacts
Read, create, update and delete the contacts in your workspace.
GET/contacts
List contacts
Newest first; deleted contacts are never returned.
| Query | Type | Notes |
|---|---|---|
page, limit | int | Default 1 and 20; limit max 100 |
search | string | Matches display name and email (case-insensitive) and part of the phone number |
tags | string | Comma-separated; contacts with any of the tags. Use the stored lowercase form |
phoneNumber | string | Exact E.164 lookup (encode + as %2B). Other filters are ignored when set |
curl -s "$BL_BASE/contacts?limit=2&tags=vip,lead" -H "Authorization: Bearer $BL_KEY"{
"success": true,
"data": [
{
"id": "cmu5bcfwc00ow6az5oh94b0qv",
"phoneNumber": "+919875552464",
"displayName": "Isha Das",
"firstName": "Isha",
"lastName": "Das",
"tags": ["vip"],
"whatsappOptOut": false,
"customFields": {},
"createdAt": "2026-09-08T09:14:27.324Z",
"updatedAt": "2026-09-17T09:14:27.324Z"
}
],
"meta": { "page": 1, "limit": 2, "total": 21, "hasMore": true }
}| Field | Type | Notes |
|---|---|---|
id | string | |
phoneNumber | string | E.164, always present |
displayName | string | The name shown in the app; built from first and last name when you set them |
firstName, lastName, email, country, company | string | Omitted when empty. country is an ISO code such as IN |
tags | string[] | Stored lowercase |
whatsappOptOut | boolean | true = opted out; messages to them are refused |
customFields | object | Free-form key/value data |
createdAt, updatedAt | string | ISO-8601 |
GET/contacts/{idOrPhone}
Get one contact
The path takes the contact id or its E.164 phone number.
curl -s "$BL_BASE/contacts/%2B919812300001" -H "Authorization: Bearer $BL_KEY"Returns one contact. Errors: 404 CONTACT_NOT_FOUND.
POST/contacts
Create a contact
| Field | Required | Notes |
|---|---|---|
phoneNumber | yes | International format; the leading + is optional here |
firstName, lastName | no | Sets displayName |
email, company | no | |
country | no | Detected from the phone number when omitted |
tags | no | string[] |
customFields | no | object |
curl -s -X POST "$BL_BASE/contacts" \
-H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
-d '{
"phoneNumber": "+919812300001",
"firstName": "Priya",
"lastName": "Shah",
"email": "priya@example.com",
"tags": ["lead"],
"customFields": { "plan": "gold" }
}'Returns 201 Created with the new contact.
| Error | When |
|---|---|
409 CONTACT_EXISTS | That number already belongs to a contact. Look it up, then use PUT |
422 INVALID_PHONE | Not a valid international number |
422 VALIDATION_ERROR | phoneNumber missing, or tags is not a list of strings |
PUT/contacts/{id}
Update a contact
Send only the fields to change: firstName, lastName, email, country, company, tags, customFields. Sending null clears a field. Returns the full updated contact.
tags and customFields are replaced, not merged. To add one tag, read the contact, append to its tags and send the whole list.curl -s -X PUT "$BL_BASE/contacts/cmuau82g4000yg8ey8n1e6wow" \
-H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
-d '{ "firstName": "Priya", "tags": ["lead", "customer"] }'| Error | When |
|---|---|
404 CONTACT_NOT_FOUND | Unknown id |
422 PHONE_NOT_EDITABLE | You sent phoneNumber; a contact's number can't change. Create a new contact instead |
DELETE/contacts/{id}
Delete a contact
Deletion is permanent; the number can be used for a new contact straight away.
curl -s -X DELETE "$BL_BASE/contacts/cmuau82g4000yg8ey8n1e6wow" -H "Authorization: Bearer $BL_KEY"{ "success": true, "data": { "success": true, "message": "Contact deleted" } }Errors: 404 CONTACT_NOT_FOUND.
Section 08
Conversations
Pro and Enterprise. Conversations cover every channel: WhatsApp, Instagram, Messenger and the website widget.
contact.phoneNumber is an internal identifier, not a phone number, so don't send messages to it.GET/conversations
List conversations
Most recently active first.
| Query | Type | Notes |
|---|---|---|
page, limit | int | limit max 100 |
status | enum | OPEN RESOLVED SNOOZED SPAM PENDING |
contactId | string | Only this contact's conversations |
curl -s "$BL_BASE/conversations?status=OPEN&limit=1" -H "Authorization: Bearer $BL_KEY"{
"success": true,
"data": [
{
"id": "cmu5bcfx500q86az5x01he86v",
"contact": {
"id": "cmu5bcfx400q66az58snzqs6g",
"phoneNumber": "+919875552464",
"displayName": "Kabir Gupta"
},
"status": "OPEN",
"assignedAgent": { "id": "cmu5bcfuo00m26az5nd12578f", "name": "Priya Sharma" },
"labels": [],
"unreadCount": 0,
"lastMessageAt": "2026-09-17T09:15:27.353Z",
"createdAt": "2026-09-17T09:14:27.353Z"
}
],
"meta": { "page": 1, "limit": 1, "total": 14, "hasMore": true }
}assignedAgent and lastMessageAt are omitted when not set.
GET/conversations/{id}
Get one conversation
Same object as above. Errors: 404 CONVERSATION_NOT_FOUND.
GET/conversations/{id}/messages
List messages
Takes page and limit (max 100). Oldest first. Deleted messages and internal agent notes are never returned.
curl -s "$BL_BASE/conversations/cmu5bcfx500q86az5x01he86v/messages?limit=50" -H "Authorization: Bearer $BL_KEY"{
"success": true,
"data": [
{
"id": "cmu5bcfx600q96az5187625dd",
"conversationId": "cmu5bcfx500q86az5x01he86v",
"direction": "inbound",
"type": "text",
"body": "Hi, I want to know the pricing for your service",
"status": "read",
"createdAt": "2026-09-17T09:14:27.354Z"
},
{
"id": "cmu5bcfx600qa6az5egxipowo",
"conversationId": "cmu5bcfx500q86az5x01he86v",
"direction": "outbound",
"type": "text",
"body": "Hello! Happy to help - which plan are you interested in?",
"status": "delivered",
"sentAt": "2026-09-17T09:15:27.353Z",
"deliveredAt": "2026-09-17T09:15:27.353Z",
"createdAt": "2026-09-17T09:15:27.353Z"
}
],
"meta": { "page": 1, "limit": 50, "total": 2, "hasMore": false }
}body, sentAt, deliveredAt and readAt are omitted when not set. Errors: 404 CONVERSATION_NOT_FOUND.
Section 09
Messages
POST/messages
Send a WhatsApp message
| Field | Required | Notes |
|---|---|---|
to | yes | E.164 with the leading + (mandatory here) |
type | yes | text or template |
body | for text | The message text |
templateName | for template | Name of an approved template |
templateLanguage | no | Defaults to en. Must match the template's language code exactly: en and en_US are different templates |
templateComponents | no | WhatsApp components array, passed through unchanged. Needed when the template has variables |
How sending works
- The contact is looked up by
toand created if it doesn't exist. - The message goes out from the workspace's first active WhatsApp number. You can't choose the number.
- The 24-hour rule: a text message is allowed only within 24 hours of the contact's last message to you. Outside that window WhatsApp accepts only approved templates, so send
type: template.
curl -s -X POST "$BL_BASE/messages" \
-H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
-d '{
"to": "+919812300001",
"type": "template",
"templateName": "order_ready",
"templateLanguage": "en",
"templateComponents": [
{ "type": "body", "parameters": [ { "type": "text", "text": "Priya" } ] }
]
}'curl -s -X POST "$BL_BASE/messages" \
-H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
-d '{ "to": "+919812300001", "type": "text", "body": "Your order has shipped." }'{
"success": true,
"data": {
"id": "cmuaxxxxxxxxxxxxxxxxxxxxx",
"conversationId": "cmuaxxxxxxxxxxxxxxxxxxxxx",
"direction": "outbound",
"type": "text",
"body": "Your order has shipped.",
"status": "sent",
"sentAt": "2026-09-21T06:10:00.000Z",
"createdAt": "2026-09-21T06:10:00.000Z"
}
}status: "sent" means WhatsApp accepted the message. Delivery and read receipts arrive later; read them with GET /conversations/{id}/messages.
| Error | When |
|---|---|
422 INVALID_PHONE | to doesn't start with +, or is too short |
422 OUTSIDE_24H_WINDOW | text sent outside the 24-hour window; use a template |
422 BODY_REQUIRED | text with an empty body |
422 TEMPLATE_REQUIRED | template without templateName |
404 TEMPLATE_NOT_FOUND | No approved template with that name and language |
422 CONTACT_OPT_OUT | The contact opted out of WhatsApp messages |
422 NO_WHATSAPP_NUMBER | The workspace has no active WhatsApp number |
422 VALIDATION_ERROR | to missing or not a string |
Section 10
Broadcasts
Pro and Enterprise. A broadcast sends one approved template to a group or list of contacts, from the workspace's first active WhatsApp number. Opted-out contacts are skipped.
GET/broadcasts
List broadcasts
Takes page, limit (max 100) and status (uppercase broadcast status).
{
"success": true,
"data": [
{
"id": "cmuaxxxxxxxxxxxxxxxxxxxxx",
"name": "September offer",
"status": "COMPLETED",
"estimatedRecipients": 1200,
"totalSent": 1187,
"totalDelivered": 1150,
"totalFailed": 13,
"createdAt": "2026-09-01T10:00:00.000Z"
}
],
"meta": { "page": 1, "limit": 20, "total": 1, "hasMore": false }
}GET/broadcasts/{id}
Get one broadcast
Same object. Errors: 404 BROADCAST_NOT_FOUND.
POST/broadcasts
Create a broadcast (draft)
Creates the broadcast as a DRAFT; nothing is sent until you call /send. In the app it shows as created by the user who made the API key.
| Field | Required | Notes |
|---|---|---|
name | yes | |
templateName | yes | Approved template |
templateLanguage | no | Defaults to en; must match exactly |
groupIds | one of | Send to the members of these groups |
contactIds | one of | Send to these contacts. Ignored if groupIds is also given |
bodyVariables | no | { "1": "Priya" } fills {{1}} in the template body |
curl -s -X POST "$BL_BASE/broadcasts" \
-H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
-d '{
"name": "October offer",
"templateName": "festive_offer",
"templateLanguage": "en",
"groupIds": ["cmuaxxxxxxxxxxxxxxxxxxxxx"],
"bodyVariables": { "1": "20%" }
}'| Error | When |
|---|---|
404 TEMPLATE_NOT_FOUND | No approved template with that name and language |
422 AUDIENCE_REQUIRED | Neither groupIds nor contactIds given |
422 BROADCAST_EMPTY_AUDIENCE | The audience has no contacts who can receive it |
422 NO_WHATSAPP_NUMBER | The workspace has no active WhatsApp number |
422 VALIDATION_ERROR | name missing, or groupIds / contactIds is not a list |
POST/broadcasts/{id}/send
Start sending
The broadcast must be DRAFT or SCHEDULED. Sending runs in the background.
curl -s -X POST "$BL_BASE/broadcasts/cmuaxxxxxxxxxxxxxxxxxxxxx/send" -H "Authorization: Bearer $BL_KEY"{ "success": true, "data": { "success": true, "broadcastId": "cmuaxxxxxxxxxxxxxxxxxxxxx", "status": "RUNNING" } }Track progress with GET /broadcasts/{id} (totalSent, totalDelivered, totalFailed) or the broadcast.completed webhook.
| Error | When |
|---|---|
404 BROADCAST_NOT_FOUND | Unknown id |
422 BROADCAST_INVALID_STATUS | Not DRAFT or SCHEDULED, for example already sent |
Section 11
Campaigns
Pro and Enterprise, and the plan must include Campaigns. Enrolling follows the same rules as the app: only your workspace's contacts that are not deleted or opted out, "enroll once" campaigns never take a contact twice, the enrollment cap applies, and the campaign must be ACTIVE.
GET/campaigns
List campaigns
Query: page, limit (max 100) and optional status. Newest first. Each item has id, name, status, enrollmentCount, exitCount, createdAt and updatedAt.
curl -s "$BL_BASE/campaigns?status=ACTIVE" -H "Authorization: Bearer $BL_KEY"POST/campaigns/{id}/enroll
Add contacts
Send at least one of:
| Field | Notes |
|---|---|
contactId | One contact id |
contactIds | Up to 100 contact ids |
phone | One contact by phone number (international format) |
curl -s -X POST "$BL_BASE/campaigns/cmuaxxxxxxxxxxxxxxxxxxxxx/enroll" \
-H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
-d '{ "phone": "+919812300001" }'{ "success": true, "data": { "enrolled": 1, "skipped": 0 } }skipped counts contacts not enrolled: unknown, deleted, opted out, already in the campaign, or over the enrollment cap.
| Error | When |
|---|---|
404 CAMPAIGN_NOT_FOUND | Unknown id |
404 CONTACT_NOT_FOUND | No contact with that phone |
422 CAMPAIGN_NOT_ACTIVE | The campaign is not ACTIVE |
422 VALIDATION_ERROR | No field given, or more than 100 contactIds |
POST/campaigns/{id}/exit
Remove contacts
Same body as enroll. Ends each contact's active or paused enrollment.
{ "success": true, "data": { "exited": 1, "skipped": 0 } }skipped counts contacts with no active or paused enrollment in the campaign.
Section 12
Templates
GET/templates
List templates
Returns only APPROVED templates by default; pass ?status=PENDING (or any template status, uppercase) for others. Sorted by name. Not paginated: every match is returned, with no meta.
curl -s "$BL_BASE/templates" -H "Authorization: Bearer $BL_KEY"{
"success": true,
"data": [
{
"id": "cmuau9tg10001m5h0mtb52b88",
"name": "order_ready",
"category": "UTILITY",
"language": "en",
"status": "APPROVED",
"bodyText": "Hello {{1}}, your order is ready."
}
]
}Use name and language from here as templateName and templateLanguage when sending a message or creating a broadcast.
Section 13
Stats and webhook management (Enterprise)
GET/stats
Workspace summary
?period= is today (default), 7d or 30d; any other value falls back to today.
curl -s "$BL_BASE/stats?period=7d" -H "Authorization: Bearer $BL_KEY"{
"success": true,
"data": {
"conversations": { "total": 21, "open": 14, "resolved": 6, "new": 13 },
"messages": { "sent": 13, "received": 13 },
"contacts": { "total": 23, "new": 2 },
"wallet": { "balance": 0, "spent": 0 }
}
}| Field | Covers |
|---|---|
conversations.total, .open, .resolved | All time |
conversations.new | Created within the period |
messages.sent, .received | Within the period |
contacts.total | All time |
contacts.new | Created within the period |
wallet.balance, .spent | Always 0; WhatsApp messaging is billed by Meta directly. Kept so existing integrations keep working |
GET/webhooks
List webhooks
These are the same webhooks as Settings → Webhooks in the app. Not paginated.
{
"success": true,
"data": [
{
"id": "cmuaub8v3003sg8eyqeeyubyy",
"name": "CRM sync",
"url": "https://example.com/hooks/botlistener",
"events": ["message.received", "contact.created"],
"isActive": true,
"failureCount": 0,
"createdAt": "2026-09-21T06:04:15.135Z"
}
]
}POST/webhooks
Create a webhook
| Field | Required | Notes |
|---|---|---|
name | yes | |
url | yes | Public https:// URL. Private and internal addresses are refused (422 WEBHOOK_URL_BLOCKED) |
events | yes | At least one event from the event list |
secret | strongly recommended | Enables signed deliveries. Without it deliveries carry no signature |
headers | no | Extra headers sent with every delivery |
curl -s -X POST "$BL_BASE/webhooks" \
-H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
-d '{
"name": "CRM sync",
"url": "https://example.com/hooks/botlistener",
"events": ["message.received", "contact.created"],
"secret": "a-long-random-string"
}'Returns the webhook with 201 Created.
DELETE/webhooks/{id}
Delete a webhook
Returns { "success": true }. Errors: 404 WEBHOOK_NOT_FOUND.
Section 14
Receiving webhooks
Delivery
Bot Listener sends a POST with a JSON body to your URL for each subscribed event. Respond with any 2xx within 10 seconds; a slower response counts as a failure. Do slow work after you respond.
{
"id": "12345",
"event": "message.received",
"timestamp": "2026-09-21T06:20:00.000Z",
"workspaceId": "cmwxxxxxxxxxxxxxxxxxxxxxx",
"data": { }
}data varies by event.
| Header | Meaning |
|---|---|
X-BotListener-Event | The event, for example message.received |
X-BotListener-Event-Id | Unique per event and identical across retries. Use it to de-duplicate |
X-BotListener-Delivery-Attempt | 1 on the first try, then 2, 3… |
X-BotListener-Timestamp | Send time, Unix milliseconds |
X-BotListener-Signature | sha256=<hex>, only when the webhook has a secret |
X-BotListener-Signature-V2 | t=<unix ms>,v1=<hex>, only when the webhook has a secret. Prefer this one |
Retries
A failed delivery (non-2xx, timeout or connection error) is retried up to 3 times, after about 30 s, 60 s and 120 s. After 10 consecutive failed attempts the webhook is disabled automatically; any success resets the count. Re-enable it in Settings → Webhooks once your endpoint is fixed, where the Logs button shows every attempt.
X-BotListener-Event-Id.Verifying the signature
Always verify against the raw request body, before parsing the JSON. Use V2: its HMAC covers the timestamp, so an old delivery can't be replayed.
- 1Split
X-BotListener-Signature-V2intotandv1. - 2Compute
HMAC-SHA256(secret, "<t>.<raw body>")as lowercase hex. - 3Compare it with
v1in constant time. - 4Reject if
tis more than 5 minutes from your clock.tis in milliseconds.
const crypto = require('crypto');
app.post('/hooks/botlistener', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-BotListener-Signature-V2') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
const expected = crypto
.createHmac('sha256', process.env.BL_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body.toString('utf8')}`)
.digest('hex');
const fresh = Math.abs(Date.now() - t) < 5 * 60 * 1000; // t is milliseconds
const valid =
parts.v1 &&
expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!fresh || !valid) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
// de-duplicate on the X-BotListener-Event-Id header, then queue your work
res.status(200).end();
});import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
fresh = abs(time.time() * 1000 - int(parts["t"])) < 5 * 60 * 1000 # t is ms
return fresh and hmac.compare_digest(expected, parts.get("v1", ""))The older X-BotListener-Signature header is sha256= + HMAC-SHA256(secret, raw body). It does not cover the timestamp, so use it only if you already verify it.
Event list
| Event | Fires when |
|---|---|
message.received | A contact sends a message |
message.sent | A message is sent, including via POST /messages |
conversation.created | A new conversation opens |
conversation.assigned | A conversation is assigned to an agent |
conversation.resolved | A conversation is resolved |
contact.created | A contact is created |
contact.updated | A contact is updated |
contact.opted_out | A contact opts out of WhatsApp messages |
broadcast.completed | A broadcast finishes sending |
campaign.enrollment.completed | A contact completes a campaign sequence |
campaign.enrollment.exited | A contact leaves a campaign sequence early |
bot.session.completed | A bot conversation finishes |
bot.session.handoff | A bot hands a conversation to a human agent |
template.approved | Meta approves a template |
template.rejected | Meta rejects a template |
payment.success | A subscription payment succeeds |
payment.failed | A subscription payment fails |
