Developers

Developer API

Let your own systems read and write your workspace's contacts, conversations, messages, broadcasts, campaigns and templates with an API key. Available on the Growth, Pro and Enterprise plans.

Base URL

https://api.botlistener.com/api/external/v1

Authentication

Bearer token or X-API-Key header

Plans

Growth, Pro and Enterprise

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.

PlanWhat the API can doAPI keysRequests / min, per key
GrowthSend messages, read templates, read and write contacts160
ProEverything in Growth, plus read conversations, read and create broadcasts, list campaigns and add or remove contacts2120
EnterpriseEverything, including stats and webhook managementUnlimited5,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

  1. 1Open Settings → API Keys in Bot Listener.
  2. 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.
  3. 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:

Base URL
https://api.botlistener.com/api/external/v1

Send your key in either header; both are accepted:

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

Keep keys server-side. A key acts on your whole workspace within its scopes. Never put one in browser JavaScript, a mobile app or a public repository.

The examples in this document use two shell variables, so they paste straight into a terminal:

Shell
export BL_BASE="https://api.botlistener.com/api/external/v1"
export BL_KEY="bl_live_..."   # from Settings → API Keys

Section 03

Conventions

Requests

  • Send JSON bodies with Content-Type: application/json. A body that is not valid JSON returns 400 INVALID_JSON.
  • A missing required field, or a field of the wrong type, returns 422 VALIDATION_ERROR, with error.details naming 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.

Single object
{ "success": true, "data": { } }
List
{
  "success": true,
  "data": [ ],
  "meta": { "page": 1, "limit": 20, "total": 21, "hasMore": true }
}
Error
{ "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).

URL-encode the + 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.

EnumValues
Conversation statusOPEN RESOLVED SNOOZED SPAM PENDING
Broadcast statusDRAFT SCHEDULED RUNNING PAUSED COMPLETED CANCELLED FAILED
Campaign statusDRAFT SCHEDULED ACTIVE PAUSED COMPLETED STOPPED ARCHIVED
Template statusDRAFT PENDING APPROVED REJECTED PAUSED DISABLED
Template categoryMARKETING 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.

ScopeGrantsPlan
messages:sendPOST /messagesGrowth+
templates:readGET /templatesGrowth+
contacts:readGET /contacts, GET /contacts/{id}Growth+
contacts:writePOST /contacts, PUT /contacts/{id}, DELETE /contacts/{id}Growth+
conversations:readGET /conversations, GET /conversations/{id}, GET /conversations/{id}/messagesPro+
broadcasts:readGET /broadcasts, GET /broadcasts/{id}Pro+
broadcasts:writePOST /broadcasts, POST /broadcasts/{id}/sendPro+
campaigns:readGET /campaignsPro+
campaigns:writePOST /campaigns/{id}/enroll, POST /campaigns/{id}/exitPro+
analytics:readGET /statsEnterprise
webhooks:manageGET /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:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current window
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix 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

HTTPerror.codeWhen
400INVALID_JSONThe request body is not valid JSON
401UNAUTHORIZEDKey missing, malformed, unknown, revoked or expired
402API_ACCESS_REQUIREDThe workspace's plan does not include API access
402API_PLAN_UPGRADE_REQUIREDThe plan does not include this endpoint's scope; the message names the plan needed
402FEATURE_NOT_AVAILABLECampaign endpoints: the plan does not include Campaigns
402SUBSCRIPTION_INACTIVEThe subscription is not active (including overdue)
402TRIAL_EXPIREDThe trial has ended
402WORKSPACE_INACTIVEThe workspace is suspended or deactivated
403API_PERMISSION_DENIEDThe key lacks the scope this endpoint needs
404*_NOT_FOUNDThe resource does not exist in this key's workspace
409CONTACT_EXISTSA contact with that phone number already exists
422VALIDATION_ERROR and endpoint codesInvalid input; codes are listed per endpoint below
429TOO_MANY_REQUESTSRate limit exceeded
500INTERNAL_ERRORUnexpected 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.
Don't auto-retry 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

Scope contacts:readGrowth+

Newest first; deleted contacts are never returned.

QueryTypeNotes
page, limitintDefault 1 and 20; limit max 100
searchstringMatches display name and email (case-insensitive) and part of the phone number
tagsstringComma-separated; contacts with any of the tags. Use the stored lowercase form
phoneNumberstringExact E.164 lookup (encode + as %2B). Other filters are ignored when set
Request
curl -s "$BL_BASE/contacts?limit=2&tags=vip,lead" -H "Authorization: Bearer $BL_KEY"
Response
{
  "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 }
}
FieldTypeNotes
idstring
phoneNumberstringE.164, always present
displayNamestringThe name shown in the app; built from first and last name when you set them
firstName, lastName, email, country, companystringOmitted when empty. country is an ISO code such as IN
tagsstring[]Stored lowercase
whatsappOptOutbooleantrue = opted out; messages to them are refused
customFieldsobjectFree-form key/value data
createdAt, updatedAtstringISO-8601

GET/contacts/{idOrPhone}

Get one contact

Scope contacts:readGrowth+

The path takes the contact id or its E.164 phone number.

Request
curl -s "$BL_BASE/contacts/%2B919812300001" -H "Authorization: Bearer $BL_KEY"

Returns one contact. Errors: 404 CONTACT_NOT_FOUND.

POST/contacts

Create a contact

Scope contacts:writeGrowth+
FieldRequiredNotes
phoneNumberyesInternational format; the leading + is optional here
firstName, lastNamenoSets displayName
email, companyno
countrynoDetected from the phone number when omitted
tagsnostring[]
customFieldsnoobject
Request
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.

ErrorWhen
409 CONTACT_EXISTSThat number already belongs to a contact. Look it up, then use PUT
422 INVALID_PHONENot a valid international number
422 VALIDATION_ERRORphoneNumber missing, or tags is not a list of strings

PUT/contacts/{id}

Update a contact

Scope contacts:writeGrowth+

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.
Request
curl -s -X PUT "$BL_BASE/contacts/cmuau82g4000yg8ey8n1e6wow" \
  -H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
  -d '{ "firstName": "Priya", "tags": ["lead", "customer"] }'
ErrorWhen
404 CONTACT_NOT_FOUNDUnknown id
422 PHONE_NOT_EDITABLEYou sent phoneNumber; a contact's number can't change. Create a new contact instead

DELETE/contacts/{id}

Delete a contact

Scope contacts:writeGrowth+

Deletion is permanent; the number can be used for a new contact straight away.

Request
curl -s -X DELETE "$BL_BASE/contacts/cmuau82g4000yg8ey8n1e6wow" -H "Authorization: Bearer $BL_KEY"
Response
{ "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.

For a non-WhatsApp contact, contact.phoneNumber is an internal identifier, not a phone number, so don't send messages to it.

GET/conversations

List conversations

Scope conversations:readPro+

Most recently active first.

QueryTypeNotes
page, limitintlimit max 100
statusenumOPEN RESOLVED SNOOZED SPAM PENDING
contactIdstringOnly this contact's conversations
Request
curl -s "$BL_BASE/conversations?status=OPEN&limit=1" -H "Authorization: Bearer $BL_KEY"
Response
{
  "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

Scope conversations:readPro+

Same object as above. Errors: 404 CONVERSATION_NOT_FOUND.

GET/conversations/{id}/messages

List messages

Scope conversations:readPro+

Takes page and limit (max 100). Oldest first. Deleted messages and internal agent notes are never returned.

Request
curl -s "$BL_BASE/conversations/cmu5bcfx500q86az5x01he86v/messages?limit=50" -H "Authorization: Bearer $BL_KEY"
Response
{
  "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

Scope messages:sendGrowth+
FieldRequiredNotes
toyesE.164 with the leading + (mandatory here)
typeyestext or template
bodyfor textThe message text
templateNamefor templateName of an approved template
templateLanguagenoDefaults to en. Must match the template's language code exactly: en and en_US are different templates
templateComponentsnoWhatsApp components array, passed through unchanged. Needed when the template has variables

How sending works

  • The contact is looked up by to and 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.
Template message
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" } ] }
    ]
  }'
Text message (inside the 24-hour window only)
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." }'
201 Created
{
  "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.

ErrorWhen
422 INVALID_PHONEto doesn't start with +, or is too short
422 OUTSIDE_24H_WINDOWtext sent outside the 24-hour window; use a template
422 BODY_REQUIREDtext with an empty body
422 TEMPLATE_REQUIREDtemplate without templateName
404 TEMPLATE_NOT_FOUNDNo approved template with that name and language
422 CONTACT_OPT_OUTThe contact opted out of WhatsApp messages
422 NO_WHATSAPP_NUMBERThe workspace has no active WhatsApp number
422 VALIDATION_ERRORto missing or not a string
A refused send changes nothing: no contact or conversation is created and no webhook fires.

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

Scope broadcasts:readPro+

Takes page, limit (max 100) and status (uppercase broadcast status).

Response
{
  "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

Scope broadcasts:readPro+

Same object. Errors: 404 BROADCAST_NOT_FOUND.

POST/broadcasts

Create a broadcast (draft)

Scope broadcasts:writePro+

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.

FieldRequiredNotes
nameyes
templateNameyesApproved template
templateLanguagenoDefaults to en; must match exactly
groupIdsone ofSend to the members of these groups
contactIdsone ofSend to these contacts. Ignored if groupIds is also given
bodyVariablesno{ "1": "Priya" } fills {{1}} in the template body
Request
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%" }
  }'
ErrorWhen
404 TEMPLATE_NOT_FOUNDNo approved template with that name and language
422 AUDIENCE_REQUIREDNeither groupIds nor contactIds given
422 BROADCAST_EMPTY_AUDIENCEThe audience has no contacts who can receive it
422 NO_WHATSAPP_NUMBERThe workspace has no active WhatsApp number
422 VALIDATION_ERRORname missing, or groupIds / contactIds is not a list

POST/broadcasts/{id}/send

Start sending

Scope broadcasts:writePro+

The broadcast must be DRAFT or SCHEDULED. Sending runs in the background.

Request
curl -s -X POST "$BL_BASE/broadcasts/cmuaxxxxxxxxxxxxxxxxxxxxx/send" -H "Authorization: Bearer $BL_KEY"
Response
{ "success": true, "data": { "success": true, "broadcastId": "cmuaxxxxxxxxxxxxxxxxxxxxx", "status": "RUNNING" } }

Track progress with GET /broadcasts/{id} (totalSent, totalDelivered, totalFailed) or the broadcast.completed webhook.

ErrorWhen
404 BROADCAST_NOT_FOUNDUnknown id
422 BROADCAST_INVALID_STATUSNot 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

Scope campaigns:readPro+

Query: page, limit (max 100) and optional status. Newest first. Each item has id, name, status, enrollmentCount, exitCount, createdAt and updatedAt.

Request
curl -s "$BL_BASE/campaigns?status=ACTIVE" -H "Authorization: Bearer $BL_KEY"

POST/campaigns/{id}/enroll

Add contacts

Scope campaigns:writePro+

Send at least one of:

FieldNotes
contactIdOne contact id
contactIdsUp to 100 contact ids
phoneOne contact by phone number (international format)
Request
curl -s -X POST "$BL_BASE/campaigns/cmuaxxxxxxxxxxxxxxxxxxxxx/enroll" \
  -H "Authorization: Bearer $BL_KEY" -H "Content-Type: application/json" \
  -d '{ "phone": "+919812300001" }'
Response
{ "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.

ErrorWhen
404 CAMPAIGN_NOT_FOUNDUnknown id
404 CONTACT_NOT_FOUNDNo contact with that phone
422 CAMPAIGN_NOT_ACTIVEThe campaign is not ACTIVE
422 VALIDATION_ERRORNo field given, or more than 100 contactIds

POST/campaigns/{id}/exit

Remove contacts

Scope campaigns:writePro+

Same body as enroll. Ends each contact's active or paused enrollment.

Response
{ "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

Scope templates:readGrowth+

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.

Request
curl -s "$BL_BASE/templates" -H "Authorization: Bearer $BL_KEY"
Response
{
  "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

Scope analytics:readEnterprise

?period= is today (default), 7d or 30d; any other value falls back to today.

Request
curl -s "$BL_BASE/stats?period=7d" -H "Authorization: Bearer $BL_KEY"
Response
{
  "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 }
  }
}
FieldCovers
conversations.total, .open, .resolvedAll time
conversations.newCreated within the period
messages.sent, .receivedWithin the period
contacts.totalAll time
contacts.newCreated within the period
wallet.balance, .spentAlways 0; WhatsApp messaging is billed by Meta directly. Kept so existing integrations keep working

GET/webhooks

List webhooks

Scope webhooks:manageEnterprise

These are the same webhooks as Settings → Webhooks in the app. Not paginated.

Response
{
  "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

Scope webhooks:manageEnterprise
FieldRequiredNotes
nameyes
urlyesPublic https:// URL. Private and internal addresses are refused (422 WEBHOOK_URL_BLOCKED)
eventsyesAt least one event from the event list
secretstrongly recommendedEnables signed deliveries. Without it deliveries carry no signature
headersnoExtra headers sent with every delivery
Request
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

Scope webhooks:manageEnterprise

Returns { "success": true }. Errors: 404 WEBHOOK_NOT_FOUND.

On Growth and Pro, webhooks are set up in Settings → Webhooks in the app instead (Growth 1 endpoint, Pro 2). Deliveries work the same way, as described next.

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.

Payload
{
  "id": "12345",
  "event": "message.received",
  "timestamp": "2026-09-21T06:20:00.000Z",
  "workspaceId": "cmwxxxxxxxxxxxxxxxxxxxxxx",
  "data": { }
}

data varies by event.

HeaderMeaning
X-BotListener-EventThe event, for example message.received
X-BotListener-Event-IdUnique per event and identical across retries. Use it to de-duplicate
X-BotListener-Delivery-Attempt1 on the first try, then 2, 3…
X-BotListener-TimestampSend time, Unix milliseconds
X-BotListener-Signaturesha256=<hex>, only when the webhook has a secret
X-BotListener-Signature-V2t=<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.

Because of retries you may receive the same event more than once. De-duplicate on 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.

  1. 1Split X-BotListener-Signature-V2 into t and v1.
  2. 2Compute HMAC-SHA256(secret, "<t>.<raw body>") as lowercase hex.
  3. 3Compare it with v1 in constant time.
  4. 4Reject if t is more than 5 minutes from your clock. t is in milliseconds.
Node.js (Express)
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();
});
Python
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

EventFires when
message.receivedA contact sends a message
message.sentA message is sent, including via POST /messages
conversation.createdA new conversation opens
conversation.assignedA conversation is assigned to an agent
conversation.resolvedA conversation is resolved
contact.createdA contact is created
contact.updatedA contact is updated
contact.opted_outA contact opts out of WhatsApp messages
broadcast.completedA broadcast finishes sending
campaign.enrollment.completedA contact completes a campaign sequence
campaign.enrollment.exitedA contact leaves a campaign sequence early
bot.session.completedA bot conversation finishes
bot.session.handoffA bot hands a conversation to a human agent
template.approvedMeta approves a template
template.rejectedMeta rejects a template
payment.successA subscription payment succeeds
payment.failedA subscription payment fails