Webhooks push real-time notifications to your server when events occur — incoming customer messages, delivery status updates, connection state changes, and partner events.
All API requests require your Developer Key in the Authorization header. No Bearer prefix — pass the key directly. See Authentication for setup and security best practices.
| Type | URL field | What it does |
|---|---|---|
| Incoming Messages | incomingMessageURL | Notifies you when a customer sends a message to your WhatsApp number |
| Status Updates | statusUpdateURL | Tracks what happened to a message you sent (see statuses below) |
| Session Status | sessionStatusURL | Tells you if your WhatsApp Lite connection is up or down (see statuses below) |
| Partner Events | eventWebhookURL | Platform-level events from Meta (see list below) |
| Chatbot Conversations | chatbotWebhookURL | Chatbot conversation events with chat participant context (see behavior). Optional outgoing replies, optional auto-stop on agent handover |
| Status | Meaning |
|---|---|
ACCEPTED | Message received by the provider (Meta) |
SENT | Message sent to the customer's number, but not yet delivered |
DELIVERED | Message arrived on the customer's phone |
READ | Customer opened and read the message |
FAILED | Message could not be delivered — check errorCode for details |
| Status | Meaning |
|---|---|
connecting | Your session is being set up or reconnecting |
success | Connected and ready — messages can be sent and received |
disconnected | Your connection is down — all messaging has stopped. Reconnect immediately |
A disconnected session status means all incoming and outgoing messages have stopped. Reconnect immediately to restore service.
- User preferences — a customer stopped or resumed marketing messages
- Template status changes — a template was approved, rejected, paused, or disabled
- Phone quality rating updates — Meta changed your number's quality rating or messaging tier
- Template quality changes — a template's quality score went up or down
- Template component changes — a template's content was modified
- User number changes — a customer switched to a new phone number
Each webhook configuration applies at one of two scopes:
| Scope | When whatsAppNumberId is | Applies to |
|---|---|---|
| Client default | omitted (null) | All WhatsApp numbers under your client that don't have an override |
| Per-number override | set to a number id | Only events received on (or sent from) that specific number |
When an event fires, the platform first looks for an override matching the event's WhatsApp number; if no override exists, it falls back to the client default. You can mix and match — keep one default for most numbers and set per-number overrides for numbers that need to point at a different system.
To stop a specific number from emitting webhooks while the rest of your client keeps the default, create an override for that number with the URL fields left empty ("").
POST /payless4messaging-service/WhatsApp/WhatsAppClientWebHookOmit whatsAppNumberId to create the client default:
{
"incomingMessageURL": "https://yourserver.com/webhooks/incoming",
"statusUpdateURL": "https://yourserver.com/webhooks/status",
"sessionStatusURL": "https://yourserver.com/webhooks/session",
"eventWebhookURL": "https://yourserver.com/webhooks/events",
"chatbotWebhookURL": "https://yourserver.com/webhooks/chatbot",
"chatbotIncludeOutgoing": false,
"chatbotStopOnAgentAssignment": false
}Or include whatsAppNumberId to create a per-number override:
{
"whatsAppNumberId": "f1e2d3c4-b5a6-9870-fedc-ba9876543210",
"incomingMessageURL": "https://yourserver.com/webhooks/incoming-vip",
"statusUpdateURL": "https://yourserver.com/webhooks/status-vip",
"sessionStatusURL": "https://yourserver.com/webhooks/session-vip",
"eventWebhookURL": "https://yourserver.com/webhooks/events-vip",
"chatbotWebhookURL": "https://yourserver.com/webhooks/chatbot-vip",
"chatbotIncludeOutgoing": true,
"chatbotStopOnAgentAssignment": false
}If you don't already have the id, call the number-management endpoint and search by MSISDN:
GET /payless4messaging-service/WhatsApp/WhatsAppClientNumbers/getForCurrentLoginUser?searchString=27123456789The searchString query parameter does a partial match on the phone number, so any fragment of the MSISDN works.
The endpoint returns WhatsAppClientNumberResponse rows. Each row wraps a nested whatsAppNumber (WhatsAppNumbers) object — that nested object's id is what you send as whatsAppNumberId. The top-level id on the row is the client–number link id, not the number itself, so don't use that one.
{
"content": [
{
"id": "11111111-1111-1111-1111-111111111111",
"whatsAppNumber": {
"id": "f1e2d3c4-b5a6-9870-fedc-ba9876543210",
"number": "+27123456789",
"whatsAppProvider": "DLG"
}
}
]
}In the example above, whatsAppNumberId would be "f1e2d3c4-b5a6-9870-fedc-ba9876543210".
You only need to provide the webhook URLs you want to use. If you only care about delivery receipts, just set statusUpdateURL. The two chatbot* boolean flags default to false and are only meaningful when chatbotWebhookURL is set.
The number id passed in whatsAppNumberId must belong to the authenticated user's client. Trying to override a number that isn't linked to your client returns 403 Forbidden. Only one default can exist per client and only one override can exist per (client, number) pair — duplicates return 409 Conflict.
PUT /payless4messaging-service/WhatsApp/WhatsAppClientWebHookInclude the id from the existing configuration. Set any URL to "" (empty string) to disable that webhook type:
{
"id": "existing-webhook-uuid",
"incomingMessageURL": "https://yourserver.com/webhooks/incoming-v2",
"statusUpdateURL": "https://yourserver.com/webhooks/status",
"sessionStatusURL": "",
"eventWebhookURL": "https://yourserver.com/webhooks/events",
"chatbotWebhookURL": "https://yourserver.com/webhooks/chatbot",
"chatbotIncludeOutgoing": true,
"chatbotStopOnAgentAssignment": true
}You cannot move a configuration between scopes via PUT. Any whatsAppNumberId value sent on an update is ignored — to convert a default into an override (or vice-versa), DELETE and recreate.
GET /payless4messaging-service/WhatsApp/WhatsAppClientWebHook/getForCurrentLoginUserReturns only the client default. Use the list endpoint below if you also want to see per-number overrides.
GET /payless4messaging-service/WhatsApp/WhatsAppClientWebHook/list?page=0&size=10Returns a paginated Page<WhatsAppClientWebHook> containing the client default (if set) and every per-number override. The default is always sorted first; overrides follow, ordered by their MSISDN.
{
"content": [
{
"id": "default-uuid",
"whatsAppNumberId": null,
"whatsAppNumberMsisdn": null,
"incomingMessageURL": "https://yourserver.com/webhooks/incoming",
"statusUpdateURL": "https://yourserver.com/webhooks/status"
},
{
"id": "override-uuid",
"whatsAppNumberId": "f1e2d3c4-b5a6-9870-fedc-ba9876543210",
"whatsAppNumberMsisdn": "+27123456789",
"incomingMessageURL": "https://yourserver.com/webhooks/incoming-vip"
}
],
"totalElements": 2,
"totalPages": 1,
"number": 0,
"size": 10
}DELETE /payless4messaging-service/WhatsApp/WhatsAppClientWebHook/{id}Pass the configuration id in the path. Deleting an override re-exposes the affected number to the client default; deleting the default leaves the per-number overrides in place but stops events for any number without an override.
WhatsApp is rolling out alpha usernames. Each customer now has a durable, business-scoped user id (BSUID, e.g. ZA.1021033720668862) that is stable for your business even if the customer changes their phone number — and that stays present on inbound events even when the phone number is withheld. Customers may also set an optional username (display only).
Incoming, chatbot, and status webhooks carry the customer BSUID — senderUserId on incoming events, senderUserId / recipientUserId on chatbot events, and recipientUserId on status receipts — plus the optional username. The phone number (sender / senderMsisdn) remains the primary identifier today — the BSUID is an additional, more durable key you can store now and rely on as phone numbers become optional. The BSUID is always present on these payloads; username is omitted only when the customer hasn't set one.
Sent to your incomingMessageURL when a customer sends a message to your WhatsApp number. The fields present vary by messageContentType.
{
"profileName": "John Smith",
"sender": "+27987654321",
"senderUserId": "ZA.1021033720668862",
"username": "johnsmith",
"receiver": "+27123456789",
"text": "Hi, I need help with my order",
"messageType": "INTERACTION",
"messageContentType": "TEXT",
"gstId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"ctId": "customer-tracking-001",
"conversationId": "conv-uuid",
"participantId": "participant-uuid",
"sentTime": "2026-01-15T10:30:00.000+00:00",
"status": "RECEIVED"
}{
"profileName": "John Smith",
"sender": "+27987654321",
"receiver": "+27123456789",
"text": null,
"caption": "Here is my invoice",
"mediaUrl": "https://media.example.com/file.pdf",
"fileExtension": "pdf",
"messageType": "INTERACTION",
"messageContentType": "MEDIA",
"gstId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"conversationId": "conv-uuid",
"participantId": "participant-uuid",
"sentTime": "2026-01-15T10:31:00.000+00:00",
"status": "RECEIVED"
}{
"profileName": "John Smith",
"sender": "+27987654321",
"receiver": "+27123456789",
"latitude": "-33.9249",
"longitude": "18.4241",
"label": "V&A Waterfront",
"address": "19 Dock Rd, Cape Town, 8001",
"messageContentType": "LOCATION",
"gstId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"sentTime": "2026-01-15T10:32:00.000+00:00",
"status": "RECEIVED"
}{
"profileName": "John Smith",
"sender": "+27987654321",
"receiver": "+27123456789",
"buttonText": "Yes, confirm order",
"buttonPayload": "confirm_order_123",
"messageContentType": "INTERACTIVE",
"gstId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"sentTime": "2026-01-15T10:33:00.000+00:00",
"status": "RECEIVED"
}{
"profileName": "John Smith",
"sender": "+27987654321",
"receiver": "+27123456789",
"text": "Contact: Jane Doe, Phone: +27111222333",
"messageContentType": "CONTACT",
"gstId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"sentTime": "2026-01-15T10:34:00.000+00:00",
"status": "RECEIVED"
}Sent when a user reacts to a message with an emoji. The text field contains the emoji and gstId references the original message that was reacted to. An empty or null text means the reaction was removed.
{
"profileName": "John Smith",
"sender": "+27987654321",
"receiver": "+27123456789",
"text": "👍",
"messageContentType": "REACTION",
"gstId": "original-message-vendor-id",
"conversationId": "conv-uuid",
"participantId": "participant-uuid"
}When a user removes a reaction, you'll receive a REACTION webhook with an empty or null text field. Use the gstId to identify which message's reaction was removed.
| Field | Type | Description |
|---|---|---|
profileName | string | Customer's WhatsApp display name |
sender | string | Customer's phone number (E.164 format) |
senderUserId | string | Customer's durable business-scoped user id (BSUID, alpha usernames), e.g. ZA.1021033720668862. Stable across phone-number changes and always present, even when sender is withheld |
username | string | Customer's optional WhatsApp username (display only). Omitted when not set |
receiver | string | Your WhatsApp number |
text | string | Message text. For REACTION, this is the emoji. For CONTACT, a formatted string. Null for media-only messages |
buttonText | string | Display text of the button the user tapped (BUTTON, INTERACTIVE) |
buttonPayload | string | Payload/ID of the button the user tapped (BUTTON, INTERACTIVE) |
latitude | string | Latitude coordinate (LOCATION only) |
longitude | string | Longitude coordinate (LOCATION only) |
label | string | Location name/label (LOCATION only) |
address | string | Street address (LOCATION only) |
mediaUrl | string | URL to download the media attachment (MEDIA only) |
caption | string | Caption attached to media (MEDIA only) |
fileExtension | string | File extension of the media (e.g., pdf, jpg, mp4) |
messageType | enum | INTERACTION or CONVERSATION — the billing model for this message |
messageContentType | enum | Content type (see table below) |
gstId | string | eCommunicate's Global System Tracking ID. For REACTION, this is the reacted-to message ID |
ctId | string | Your custom tracking ID |
conversationId | string | Conversation UUID (if part of a conversation) |
participantId | string | Conversation engine participant UUID. Not the same as the live-chat chatParticipantId used to fetch messages via the live-chat API — those two systems use separate participant identifiers. Use chatParticipantId from the chatbot conversation payload when correlating with live chat |
sentTime | string | Timestamp when the message was sent |
status | string | Message status (e.g., RECEIVED) |
cost | string | Message cost (if applicable) |
templateId | string | Template UUID (for template responses) |
templateName | string | Template name (for template responses) |
bodyParameters | object | Template body parameter values, keyed as "parameter 1", "parameter 2", etc. |
tqrmtId | string | Template Quick Reply Message Tracking ID |
conversationStatusCode | string | Conversation status code |
conversationStatusMessage | string | Conversation status description |
requestId | string | Internal request tracking ID |
errorCode | string | Error code (empty string if no error) |
errorCodeString | string | Error description (empty string if no error) |
messageContentType | Trigger | Key fields |
|---|---|---|
TEXT | Customer sends a text message | text |
MEDIA | Customer sends image, video, audio, document, or sticker | mediaUrl, caption, fileExtension |
LOCATION | Customer shares a location | latitude, longitude, label, address |
BUTTON | Customer taps a template button | buttonText, buttonPayload |
INTERACTIVE | Customer taps a quick reply, list item, or flow button | buttonText, buttonPayload |
CONTACT | Customer shares a contact card | text (formatted as "Contact: Name, Phone: Number") |
REACTION | Customer reacts to a message with an emoji | text (emoji), gstId (reacted message ID) |
CAROUSEL | Customer interacts with a carousel card | buttonText, buttonPayload |
Sent to your statusUpdateURL when the delivery status of an outbound message changes.
{
"sender": "+27123456789",
"receiver": "+27987654321",
"recipientUserId": "ZA.1021033720668862",
"gstId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"ctId": "customer-tracking-001",
"conversationCtId": "conversation-tracking-001",
"status": "DELIVERED",
"sendTime": "2026-01-15T10:30:02.000+00:00",
"deliveryTime": "2026-01-15T10:30:05.000+00:00",
"readTime": null,
"playedTime": null,
"errorCode": null,
"errorCodeString": null,
"conversationId": "conv-uuid",
"participantId": "participant-uuid",
"cost": "0.05",
"tqrmtId": null
}| Field | Type | Description |
|---|---|---|
sender | string | Your WhatsApp number (the sending number) |
receiver | string | Recipient's phone number |
recipientUserId | string | Recipient's (customer's) durable BSUID (alpha usernames), e.g. ZA.1021033720668862. The customer is the recipient of the outbound message this receipt is for |
gstId | string | eCommunicate's Global System Tracking ID |
ctId | string | Your custom tracking ID (set when sending) |
conversationCtId | string | Conversation-level tracking ID |
status | enum | ACCEPTED, SENT, DELIVERED, READ, PLAYED, FAILED |
sendTime | string | Timestamp when the message was sent to WhatsApp servers |
deliveryTime | string | Timestamp when the message was delivered to the recipient's device |
readTime | string | Timestamp when the recipient read the message |
playedTime | string | Timestamp when the recipient played a voice note (voice/PTT messages only) |
errorCode | string | Numeric error code (only when FAILED) |
errorCodeString | string | Human-readable error description (only when FAILED) — see Error Handling |
conversationId | string | Conversation UUID |
participantId | string | Chat participant UUID |
cost | string | Message cost |
requestId | string | Internal request tracking ID |
tqrmtId | string | Template Quick Reply Message Tracking ID |
ACCEPTED ──> SENT ──> DELIVERED ──> READ ──> PLAYED
└──> FAILEDPLAYED applies only to voice notes (voice/PTT messages) — it fires when the recipient listens to the audio, and implies READ. Other message types stop at READ.
FAILED is a terminal status — the message will not be retried automatically. Check errorCode and errorCodeString for details and handle retries in your application if needed.
Sent to your sessionStatusURL when a WhatsApp Lite session connection state changes (e.g., connected, disconnected, or QR code scan required).
{
"whatsAppNumber": "+27123456789",
"status": "Sync Complete. You can now send messages",
"statusType": "success",
"timestamp": 1705312200000
}| Field | Type | Description |
|---|---|---|
whatsAppNumber | string | The WhatsApp number whose session changed |
status | string | Descriptive session status from WhatsApp (see status values below) |
statusType | enum | Session state: connecting, success, disconnected |
timestamp | long | Unix timestamp (milliseconds) of the status change |
status | Description |
|---|---|
Connecting... | Connection attempt in progress |
Connected | Successfully connected to WhatsApp |
Connected (Authenticated) | Connected and authenticated |
Paired Successfully | Device pairing completed |
Paired Failed | Device pairing failed |
Logged In | Session logged in |
Logged In Successfully | Login completed |
Logged Out | Session logged out |
Logged out from the system | Logout by system |
Logging out... | Logout in progress |
Disconnected | Session disconnected — all messaging has stopped |
Disconnected by API request | Disconnected via API call |
Disconnected: Logged in on another device | Session replaced on another device |
Disconnected: QR Code timeout | QR code pairing timed out |
Initializing... | Session initializing |
Sync Complete. You can now send messages | App state sync finished, session ready |
Syncing metadata (errors occurred)... | Sync in progress with errors |
No Session | No active session exists |
A Disconnected status means all incoming and outgoing messages have stopped. Urgently reconnect via the QR Code, or if you have violated WhatsApp's terms and conditions, your number may be flagged and disconnected for up to 24 hours — you will not be able to reconnect until the flagging period ends.
QR Code Scanned ──> connecting ──> success
└──> disconnectedstatusType | Trigger events | Description |
|---|---|---|
connecting | QR code scanned, pair success, connected, logged in, app state sync in progress | Session is being established or reconnecting |
success | App state sync complete | Session is fully connected and ready |
disconnected | Pair failure, connection failure, disconnected, logged out, stream replaced | Session has been lost or terminated |
Session status webhooks are only sent for WhatsApp Lite numbers. WhatsApp Cloud numbers are managed by Meta and do not emit session events.
Event webhooks are sent to your eventWebhookURL when platform-level events occur — template approvals, quality rating changes, user preferences, and number changes. Each payload includes an eventType field and only the fields relevant to that event (null fields are omitted).
Every event webhook includes these fields:
| Field | Type | Description |
|---|---|---|
eventType | string | The event type identifier (see below) |
whatsAppNumber | string | The WhatsApp number this event relates to |
timestamp | long | Unix timestamp (milliseconds) of the event |
Triggered when Meta updates the quality rating or messaging tier of your WhatsApp number.
{
"eventType": "PHONE_QUALITY_CHANGED",
"whatsAppNumber": "+27123456789",
"timestamp": 1705312200000,
"qualityRating": "GREEN",
"messagingLimitTier": "TIER_10K",
"channelStatus": "CONNECTED"
}| Field | Type | Values |
|---|---|---|
qualityRating | string | GREEN (high), YELLOW (medium), RED (low) |
messagingLimitTier | string | TIER_1K, TIER_10K, TIER_100K, TIER_UNLIMITED |
channelStatus | string | CONNECTED, DISCONNECTED, FLAGGED, RESTRICTED |
If qualityRating drops to YELLOW or RED, review your messaging practices immediately. Continued low quality can result in Meta restricting your number.
Triggered when a WhatsApp template's approval status changes — approved, rejected, paused, or disabled by Meta.
{
"eventType": "TEMPLATE_STATUS_CHANGED",
"whatsAppNumber": "+27123456789",
"timestamp": 1705312200000,
"templateName": "seasonal_promo_text",
"templateStatus": "REJECTED",
"rejectedReason": "Template contains promotional content not matching the selected category"
}| Field | Type | Description |
|---|---|---|
templateName | string | Name of the affected template |
templateStatus | string | APPROVED, REJECTED, PAUSED, DISABLED |
rejectedReason | string | Reason for rejection (only present when REJECTED) |
Triggered when a template's quality score changes based on user feedback and engagement metrics.
{
"eventType": "TEMPLATE_QUALITY_CHANGED",
"whatsAppNumber": "+27123456789",
"timestamp": 1705312200000,
"templateName": "order_confirmation",
"previousQualityScore": "GREEN",
"newQualityScore": "YELLOW"
}| Field | Type | Description |
|---|---|---|
templateName | string | Name of the affected template |
previousQualityScore | string | Previous quality: GREEN, YELLOW, RED, UNKNOWN |
newQualityScore | string | New quality: GREEN, YELLOW, RED, UNKNOWN |
Triggered when a template's components are modified (e.g., after editing and resubmitting for approval).
{
"eventType": "TEMPLATE_COMPONENTS_CHANGED",
"whatsAppNumber": "+27123456789",
"timestamp": 1705312200000,
"templateName": "welcome_message",
"templateLanguage": "en",
"templateElement": "BODY"
}| Field | Type | Description |
|---|---|---|
templateName | string | Name of the affected template |
templateLanguage | string | Language code of the template (e.g., en, af, zu) |
templateElement | string | Component that changed: HEADER, BODY, FOOTER, BUTTONS |
Triggered when a WhatsApp user opts out of or back into marketing messages via WhatsApp's built-in marketing preference flow.
{
"eventType": "USER_PREFERENCE",
"whatsAppNumber": "+27123456789",
"timestamp": 1705312200000,
"waId": "27987654321",
"category": "MARKETING",
"preferenceValue": "stop"
}| Field | Type | Description |
|---|---|---|
waId | string | The user's WhatsApp ID (phone number without +) |
category | string | Message category (e.g., MARKETING) |
preferenceValue | string | stop (opted out) or resume (opted back in) |
When you receive a stop preference, you must stop sending marketing messages to that user immediately. Continuing to send after an opt-out violates Meta's policies and can result in your number being restricted.
Triggered when a WhatsApp user migrates to a new phone number. Update your contact records to continue reaching this user.
{
"eventType": "USER_CHANGED_NUMBER",
"whatsAppNumber": "+27123456789",
"timestamp": 1705312200000,
"oldNumber": "+27987654321",
"newNumber": "+27111222333"
}| Field | Type | Description |
|---|---|---|
oldNumber | string | The user's previous phone number |
newNumber | string | The user's new phone number |
app.post('/webhooks/events', (req, res) => {
const event = req.body;
console.log(`Event: ${event.eventType}`);
console.log(`Number: ${event.whatsAppNumber}`);
switch (event.eventType) {
case 'PHONE_QUALITY_CHANGED':
console.log(`Quality: ${event.qualityRating}, Tier: ${event.messagingLimitTier}`);
if (event.qualityRating === 'RED') alertTeam('Phone quality dropped to RED');
break;
case 'TEMPLATE_STATUS_CHANGED':
console.log(`Template "${event.templateName}" → ${event.templateStatus}`);
if (event.templateStatus === 'REJECTED') {
console.log(`Reason: ${event.rejectedReason}`);
}
break;
case 'TEMPLATE_QUALITY_CHANGED':
console.log(`Template "${event.templateName}": ${event.previousQualityScore} → ${event.newQualityScore}`);
break;
case 'TEMPLATE_COMPONENTS_CHANGED':
console.log(`Template "${event.templateName}" (${event.templateLanguage}) component changed: ${event.templateElement}`);
break;
case 'USER_PREFERENCE':
console.log(`User ${event.waId} ${event.preferenceValue} ${event.category}`);
// Update your opt-out list
updateMarketingPreference(event.waId, event.preferenceValue);
break;
case 'USER_CHANGED_NUMBER':
console.log(`Number change: ${event.oldNumber} → ${event.newNumber}`);
// Update contact records
updateContactNumber(event.oldNumber, event.newNumber);
break;
}
res.status(200).send('OK');
});Sent to your chatbotWebhookURL when a chatbot conversation event occurs. Unlike incomingMessageURL, this webhook carries chat participant context — chatParticipantId, conversationStatus, conversationClosed, and the currently and previously assigned agents — so you can mirror the conversation lifecycle on your side.
This webhook coexists with incomingMessageURL; both fire independently when a customer message arrives.
{
"eventId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"direction": "INCOMING",
"timestamp": 1705312200000,
"chatParticipantId": "participant-uuid",
"conversationStatus": "ACTIVE",
"conversationClosed": false,
"conversationMissed": false,
"chatBotConversation": true,
"clientRef": "your-client-ref",
"senderMsisdn": "27987654321",
"receiverMsisdn": "+27123456789",
"messageId": "internal-message-id",
"ctId": "customer-tracking-001",
"messageType": "TEXT",
"messageText": "Hi, I need help with my order"
}When the customer's last received message was a template send (notification / utility / marketing template), the platform tags the incoming reply with the originating template so you can correlate the response back to the campaign that triggered it. This is most common for BUTTON replies to quick-reply buttons on the template, but applies to any incoming messageType that follows a template — TEXT, BUTTON, INTERACTIVE, REACTION, etc.
{
"eventId": "741f73c4-d8f9-4553-ae71-938b56d9ce90",
"direction": "INCOMING",
"timestamp": 1779878219796,
"chatParticipantId": "aef65b5a-23a4-462a-a275-9aa2f3e0e56e",
"conversationClosed": false,
"conversationMissed": false,
"chatBotConversation": true,
"clientRef": "Growthpoint",
"senderMsisdn": "27987654321",
"receiverMsisdn": "+27123456789",
"messageId": "2b6d06bf-98bb-4968-9d53-8f7ae7340eac",
"messageType": "BUTTON",
"messageText": "Opt-Out",
"templateId": "9874dc22-d4f7-4b81-b991-cd7f364faa2c",
"templateName": "user_optout"
}templateId and templateName are populated only when the last outbound message on the chat participant was a template send. If the customer's reply follows a free-form session message (bot widget reply, agent message), both fields are omitted.
When the customer replies by quoting an earlier message (WhatsApp's reply-to feature), the v2 payload carries a snapshot of the quoted message — its id in quotedMessageId and a text snapshot in quotedText — so you can render the reply in context even if you never stored the original.
{
"eventId": "c3a1e5d2-9f8b-4c7a-b6e1-2d4f6a8c0b13",
"direction": "INCOMING",
"timestamp": 1779878300000,
"chatParticipantId": "aef65b5a-23a4-462a-a275-9aa2f3e0e56e",
"conversationClosed": false,
"conversationMissed": false,
"chatBotConversation": true,
"clientRef": "Growthpoint",
"senderMsisdn": "27987654321",
"receiverMsisdn": "+27123456789",
"messageId": "2b6d06bf-98bb-4968-9d53-8f7ae7340eac",
"messageType": "TEXT",
"messageText": "Yes, that order",
"quotedMessageId": "3EB09839C39C57F54A9EC5",
"quotedText": "Hi, you have reached Growthpoint. Is this about order #1234?"
}quotedMessageId is the WhatsApp id of the message being replied to; quotedText is a snapshot of that message's text captured when the reply arrived. Both are omitted when the incoming message is not a reply/quote.
Only emitted when chatbotIncludeOutgoing is true.
{
"eventId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"direction": "OUTGOING",
"timestamp": 1705312203000,
"chatParticipantId": "participant-uuid",
"conversationStatus": "ACTIVE",
"conversationClosed": false,
"conversationMissed": false,
"chatBotConversation": true,
"clientRef": "your-client-ref",
"senderMsisdn": "+27123456789",
"receiverMsisdn": "27987654321",
"messageId": "internal-bot-message-id",
"ctId": "bot-reply-tracking-001",
"messageType": "TEXT",
"messageText": "Sure, what's your order number?"
}When a chat is assigned to a human agent, payloads include assignedAgentId and assignedAgentName. If chatbotStopOnAgentAssignment is true, no further payloads are emitted while assignedAgentId is populated.
{
"eventId": "5e9f8b3c-7a1b-4d2e-8c3f-1a2b3c4d5e6f",
"direction": "INCOMING",
"timestamp": 1705312300000,
"chatParticipantId": "participant-uuid",
"conversationStatus": "ACTIVE",
"conversationClosed": false,
"conversationMissed": false,
"chatBotConversation": false,
"assignedAgentId": "agent-uuid",
"assignedAgentName": "Alice Smith",
"lastAssignedAgentId": "previous-agent-uuid",
"lastAssignedAgentName": "Bob Jones",
"clientRef": "your-client-ref",
"senderMsisdn": "27987654321",
"receiverMsisdn": "+27123456789",
"messageId": "internal-message-id",
"messageType": "TEXT",
"messageText": "Thanks for the help"
}| Field | Type | Description |
|---|---|---|
eventId | string (UUID) | Server-generated unique event id. Use for receiver-side idempotency / deduplication |
direction | enum | INCOMING (customer → bot) or OUTGOING (bot → customer) |
timestamp | long | Unix timestamp (milliseconds) the event was emitted |
chatParticipantId | string (UUID) | Chat participant the event belongs to |
conversationStatus | string | Current conversation status (e.g., NEW, ACTIVE) |
conversationClosed | boolean | true when the conversation has been closed |
conversationMissed | boolean | true when the conversation came after working hours or failed to be assigned to an agent cause the agent was offline |
chatBotConversation | boolean | true while the chatbot is driving the conversation |
assignedAgentId | string (UUID) | Currently assigned human agent — omitted when the bot is in control |
assignedAgentName | string | Currently assigned agent's display name (first + last) — omitted when no agent assigned |
lastAssignedAgentId | string (UUID) | Previously assigned agent (set after a chat closes/reopens) — omitted if none |
lastAssignedAgentName | string | Previously assigned agent's display name — omitted if none |
clientRef | string | Your client reference |
senderMsisdn | string | Sender MSISDN (customer for INCOMING, your WhatsApp number for OUTGOING) |
receiverMsisdn | string | Receiver MSISDN (your WhatsApp number for INCOMING, customer for OUTGOING) |
messageId | string | Internal message id |
ctId | string | Custom tracking id (when set on send) |
messageType | string | Message content type. For INCOMING one of: TEXT, LOCATION, MEDIA, BUTTON, ALL, INTERACTIVE, REACTION, CAROUSEL, USER_ACTION, CONTACT. For OUTGOING typically Combined (a multi-widget bot reply), or one of the values above when the bot sends a single action widget |
messageText | string | Message text body. For INCOMING this is the customer's text (or button payload, location label, etc., depending on messageType). For OUTGOING Combined replies this is a JSON-serialised list of widgets. Omitted when no text (e.g., media-only) |
mediaUrl | string | Media URL (only present for media messages — omitted otherwise) |
templateId | string (UUID) | Originating template id — present on INCOMING events when the last outbound message to this chat participant was a template send (the reply is attributed to that template). Omitted when the prior outbound was a free-form session message |
templateName | string | Originating template name (matches templateId). Present and omitted under the same conditions as templateId |
Null fields are omitted from the payload. The v2 payload adds a few more fields — see v2 field reference.
The webhook is gated by three configuration values on WhatsAppClientWebHook:
| Flag | Default | Effect |
|---|---|---|
chatbotWebhookURL | unset | When unset or blank, no chatbot webhooks fire |
chatbotIncludeOutgoing | false | When true, also emit OUTGOING events for bot widget replies. When false, only INCOMING events fire |
chatbotStopOnAgentAssignment | false | When true, suppress emit while assignedAgentId is set on the chat participant. When false, continue emitting after handover |
OUTGOING events cover bot widget replies only (text, button, list, media emitted by flow nodes). Template / notification sends are tracked via statusUpdateURL, not here.
The chatbot webhook supports two payload shapes, selected per-webhook via the chatbotPayloadVersion field on WhatsAppClientWebHook:
| Version | Effect |
|---|---|
v1 (default; also when chatbotPayloadVersion is omitted or null) | Flat payload as shown above |
v2 | Same flat fields plus three flat team fields — teamId, teamName, routedToAnotherTeam — the quoted-reply fields quotedMessageId / quotedText, and the alpha-username fields senderUserId / recipientUserId / username (customer BSUID + display username) |
v2 is a strict superset of v1 — every existing field stays at the same top-level path, so v1 handlers continue working unmodified. v2 adds team-routing context and quoted-reply context on top of the flat payload.
Omitting chatbotPayloadVersion (or sending null) currently selects v1, but v1 is on a deprecation path: the default will flip to v2 and the chatbotPayloadVersion field will be removed once the migration completes. New integrations should set "v2" explicitly so they aren't affected when the default changes.
Reply with a single call to Send an agent message (v2) using the chatParticipantId from the payload — set channel and text. For media, first upload the file via Upload media for an agent message (v2) and send the returned mediaUrl. The sending agent is resolved from your API key, so you don't echo any participant or agent object back.
{
"eventId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"direction": "INCOMING",
"timestamp": 1705312200000,
"chatParticipantId": "participant-uuid",
"conversationStatus": "ACTIVE",
"conversationClosed": false,
"conversationMissed": false,
"chatBotConversation": true,
"assignedAgentId": "agent-uuid",
"assignedAgentName": "Alice Smith",
"clientRef": "your-client-ref",
"senderMsisdn": "27987654321",
"receiverMsisdn": "+27123456789",
"messageId": "internal-message-id",
"ctId": "customer-tracking-001",
"messageType": "TEXT",
"messageText": "Hi, I need help with my order",
"senderUserId": "ZA.1021033720668862",
"username": "johnsmith",
"teamId": "team-uuid",
"teamName": "Sales",
"routedToAnotherTeam": true
}When the customer's reply follows a template send, the v2 payload carries the same templateId / templateName attribution fields on the flat payload, alongside the v2 team fields (teamId, teamName, routedToAnotherTeam).
{
"eventId": "741f73c4-d8f9-4553-ae71-938b56d9ce90",
"direction": "INCOMING",
"timestamp": 1779878219796,
"chatParticipantId": "aef65b5a-23a4-462a-a275-9aa2f3e0e56e",
"conversationStatus": "ACTIVE",
"conversationClosed": false,
"conversationMissed": false,
"chatBotConversation": true,
"clientRef": "Growthpoint",
"senderMsisdn": "27987654321",
"receiverMsisdn": "+27123456789",
"messageId": "2b6d06bf-98bb-4968-9d53-8f7ae7340eac",
"messageType": "BUTTON",
"messageText": "Opt-Out",
"templateId": "9874dc22-d4f7-4b81-b991-cd7f364faa2c",
"templateName": "user_optout",
"teamId": "team-uuid",
"teamName": "Sales",
"routedToAnotherTeam": false
}| Field | Type | Description |
|---|---|---|
teamId | string (UUID) | Team currently handling the chat. Omitted when the chat isn't routed to a team |
teamName | string | Display name of the team currently handling the chat. Omitted when the chat isn't routed to a team |
routedToAnotherTeam | boolean | Emitted on assignment events. true when this assignment moved the chat to a different team than its previous assignment, false when the team is unchanged. Omitted on events that aren't assignments |
quotedMessageId | string | WhatsApp id of the message the customer quoted/replied to. Present on INCOMING events when the message is a reply/quote; omitted otherwise |
quotedText | string | Snapshot of the quoted message's text, captured when the reply arrived. Present and omitted under the same conditions as quotedMessageId |
senderUserId | string | Customer's durable BSUID (alpha usernames) on INCOMING events, e.g. ZA.1021033720668862. Stable across phone-number changes; present even when senderMsisdn is withheld. Always present on INCOMING events |
recipientUserId | string | Customer's BSUID on OUTGOING events (the customer is the recipient of the bot reply). Always present on OUTGOING events |
username | string | Customer's optional WhatsApp username (display only). Omitted when not set |
Agent identity and conversation lifecycle are available on the flat fields (assignedAgentId, assignedAgentName, lastAssignedAgentId, lastAssignedAgentName, conversationStatus, conversationClosed, …) — no nested objects, and no credentials are ever sent. Null fields are omitted.
v2 is opt-in per webhook configuration. Send chatbotPayloadVersion: "v2" on POST / PUT /payless4messaging-service/WhatsApp/WhatsAppClientWebHook:
{
"chatbotWebhookURL": "https://yourserver.com/webhooks/chatbot",
"chatbotIncludeOutgoing": true,
"chatbotStopOnAgentAssignment": false,
"chatbotPayloadVersion": "v2"
}Omit the field or send "v1" to keep the flat payload. Switching between versions is reversible with a single PUT.
Setting chatbotWebhookURL does not disable incomingMessageURL. If both URLs are configured, both fire on every customer message — incomingMessageURL always, chatbotWebhookURL according to the gating flags above. Use eventId to deduplicate if you point both URLs at the same handler.
app.post('/webhooks/chatbot', (req, res) => {
const event = req.body;
console.log(`Event ${event.eventId} (${event.direction})`);
console.log(`Participant: ${event.chatParticipantId}, status: ${event.conversationStatus}`);
if (event.assignedAgentId) {
console.log(`Assigned to agent: ${event.assignedAgentName} (${event.assignedAgentId})`);
} else {
console.log('Bot is in control');
}
if (event.direction === 'INCOMING') {
console.log(`Customer ${event.senderMsisdn}: ${event.messageText}`);
} else {
console.log(`Bot reply to ${event.receiverMsisdn}: ${event.messageText}`);
}
res.status(200).send('OK');
});const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/incoming', (req, res) => {
const message = req.body;
console.log(`From: ${message.sender} (${message.profileName})`);
console.log(`Type: ${message.messageContentType}`);
switch (message.messageContentType) {
case 'TEXT':
console.log(`Text: ${message.text}`);
break;
case 'MEDIA':
console.log(`Media: ${message.mediaUrl} (${message.fileExtension})`);
if (message.caption) console.log(`Caption: ${message.caption}`);
break;
case 'LOCATION':
console.log(`Location: ${message.latitude}, ${message.longitude}`);
break;
case 'BUTTON':
case 'INTERACTIVE':
console.log(`Button: ${message.buttonText} (${message.buttonPayload})`);
break;
case 'CONTACT':
console.log(`Contact: ${message.text}`);
break;
case 'REACTION':
console.log(`Reaction: ${message.text || '(removed)'} on message ${message.gstId}`);
break;
}
// Queue for async processing
messageQueue.add('incoming', message);
// Respond immediately — do not block
res.status(200).send('OK');
});
app.listen(3000);app.post('/webhooks/status', (req, res) => {
const update = req.body;
console.log(`Message ID: ${update.gstId}`);
console.log(`Status: ${update.status}`);
switch (update.status) {
case 'SENT':
console.log(`Sent at: ${update.sendTime}`);
break;
case 'DELIVERED':
console.log(`Delivered at: ${update.deliveryTime}`);
break;
case 'READ':
console.log(`Read at: ${update.readTime}`);
break;
case 'FAILED':
console.error(`Failed: ${update.errorCodeString} (code: ${update.errorCode})`);
// Trigger retry logic or alert
break;
}
res.status(200).send('OK');
});- Respond within 5 seconds — Return
200 OKimmediately to prevent retries. Do heavy processing asynchronously. - Process asynchronously — Queue incoming webhooks (Redis, RabbitMQ, SQS) for background processing rather than handling inline.
- Deduplicate — Use
gstIdorctIdto detect duplicate webhook deliveries and ensure idempotent processing. - Use HTTPS only — Always use HTTPS URLs with a valid SSL certificate for your webhook endpoints.
- Log everything — Log all webhook payloads with timestamps for debugging delivery issues. Redact sensitive data.
- Monitor uptime — If your webhook endpoint is down, you'll miss events. Use health checks and uptime monitoring.
- Handle all content types — Incoming messages can be
TEXT,MEDIA,LOCATION,BUTTON,INTERACTIVE,CONTACT,REACTION, orCAROUSEL. - Handle reaction removals — A
REACTIONwith emptytextmeans the user removed their reaction.