Skip to content
Last updated

Webhooks push real-time notifications to your server when events occur — incoming customer messages, delivery status updates, connection state changes, and partner events.

Authentication Required

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.


Webhook types

TypeURL fieldWhat it does
Incoming MessagesincomingMessageURLNotifies you when a customer sends a message to your WhatsApp number
Status UpdatesstatusUpdateURLTracks what happened to a message you sent (see statuses below)
Session StatussessionStatusURLTells you if your WhatsApp Lite connection is up or down (see statuses below)
Partner EventseventWebhookURLPlatform-level events from Meta (see list below)
Chatbot ConversationschatbotWebhookURLChatbot conversation events with chat participant context (see behavior). Optional outgoing replies, optional auto-stop on agent handover

Message delivery statuses

StatusMeaning
ACCEPTEDMessage received by the provider (Meta)
SENTMessage sent to the customer's number, but not yet delivered
DELIVEREDMessage arrived on the customer's phone
READCustomer opened and read the message
FAILEDMessage could not be delivered — check errorCode for details

Session statuses (WhatsApp Lite only)

StatusMeaning
connectingYour session is being set up or reconnecting
successConnected and ready — messages can be sent and received
disconnectedYour connection is down — all messaging has stopped. Reconnect immediately
Disconnected Status (WhatsApp Lite only)

A disconnected session status means all incoming and outgoing messages have stopped. Reconnect immediately to restore service.

Partner events


Webhook scope

Each webhook configuration applies at one of two scopes:

ScopeWhen whatsAppNumberId isApplies to
Client defaultomitted (null)All WhatsApp numbers under your client that don't have an override
Per-number overrideset to a number idOnly 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.

Disabling webhooks for one number

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 ("").


Configure webhooks

Create webhook configuration

POST /payless4messaging-service/WhatsApp/WhatsAppClientWebHook

Omit 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
}
Looking up `whatsAppNumberId`

If you don't already have the id, call the number-management endpoint and search by MSISDN:

GET /payless4messaging-service/WhatsApp/WhatsAppClientNumbers/getForCurrentLoginUser?searchString=27123456789

The 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".

Optional fields

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.

Update webhook configuration

PUT /payless4messaging-service/WhatsApp/WhatsAppClientWebHook

Include 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
}
Scope is locked on update

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 default configuration

GET /payless4messaging-service/WhatsApp/WhatsAppClientWebHook/getForCurrentLoginUser

Returns only the client default. Use the list endpoint below if you also want to see per-number overrides.

List all configurations

GET /payless4messaging-service/WhatsApp/WhatsAppClientWebHook/list?page=0&size=10

Returns 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 webhook configuration

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.


Webhook payload schemas

Alpha usernames (BSUID)

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.

Incoming message payload

Sent to your incomingMessageURL when a customer sends a message to your WhatsApp number. The fields present vary by messageContentType.

Text message

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

Media message (image, video, audio, document, sticker)

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

Location message

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

Button / interactive reply

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

Contact message

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

Reaction message

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"
}
Reaction removal

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 reference

FieldTypeDescription
profileNamestringCustomer's WhatsApp display name
senderstringCustomer's phone number (E.164 format)
senderUserIdstringCustomer'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
usernamestringCustomer's optional WhatsApp username (display only). Omitted when not set
receiverstringYour WhatsApp number
textstringMessage text. For REACTION, this is the emoji. For CONTACT, a formatted string. Null for media-only messages
buttonTextstringDisplay text of the button the user tapped (BUTTON, INTERACTIVE)
buttonPayloadstringPayload/ID of the button the user tapped (BUTTON, INTERACTIVE)
latitudestringLatitude coordinate (LOCATION only)
longitudestringLongitude coordinate (LOCATION only)
labelstringLocation name/label (LOCATION only)
addressstringStreet address (LOCATION only)
mediaUrlstringURL to download the media attachment (MEDIA only)
captionstringCaption attached to media (MEDIA only)
fileExtensionstringFile extension of the media (e.g., pdf, jpg, mp4)
messageTypeenumINTERACTION or CONVERSATION — the billing model for this message
messageContentTypeenumContent type (see table below)
gstIdstringeCommunicate's Global System Tracking ID. For REACTION, this is the reacted-to message ID
ctIdstringYour custom tracking ID
conversationIdstringConversation UUID (if part of a conversation)
participantIdstringConversation 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
sentTimestringTimestamp when the message was sent
statusstringMessage status (e.g., RECEIVED)
coststringMessage cost (if applicable)
templateIdstringTemplate UUID (for template responses)
templateNamestringTemplate name (for template responses)
bodyParametersobjectTemplate body parameter values, keyed as "parameter 1", "parameter 2", etc.
tqrmtIdstringTemplate Quick Reply Message Tracking ID
conversationStatusCodestringConversation status code
conversationStatusMessagestringConversation status description
requestIdstringInternal request tracking ID
errorCodestringError code (empty string if no error)
errorCodeStringstringError description (empty string if no error)

Content types

messageContentTypeTriggerKey fields
TEXTCustomer sends a text messagetext
MEDIACustomer sends image, video, audio, document, or stickermediaUrl, caption, fileExtension
LOCATIONCustomer shares a locationlatitude, longitude, label, address
BUTTONCustomer taps a template buttonbuttonText, buttonPayload
INTERACTIVECustomer taps a quick reply, list item, or flow buttonbuttonText, buttonPayload
CONTACTCustomer shares a contact cardtext (formatted as "Contact: Name, Phone: Number")
REACTIONCustomer reacts to a message with an emojitext (emoji), gstId (reacted message ID)
CAROUSELCustomer interacts with a carousel cardbuttonText, buttonPayload

Status update payload

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
}
FieldTypeDescription
senderstringYour WhatsApp number (the sending number)
receiverstringRecipient's phone number
recipientUserIdstringRecipient's (customer's) durable BSUID (alpha usernames), e.g. ZA.1021033720668862. The customer is the recipient of the outbound message this receipt is for
gstIdstringeCommunicate's Global System Tracking ID
ctIdstringYour custom tracking ID (set when sending)
conversationCtIdstringConversation-level tracking ID
statusenumACCEPTED, SENT, DELIVERED, READ, PLAYED, FAILED
sendTimestringTimestamp when the message was sent to WhatsApp servers
deliveryTimestringTimestamp when the message was delivered to the recipient's device
readTimestringTimestamp when the recipient read the message
playedTimestringTimestamp when the recipient played a voice note (voice/PTT messages only)
errorCodestringNumeric error code (only when FAILED)
errorCodeStringstringHuman-readable error description (only when FAILED) — see Error Handling
conversationIdstringConversation UUID
participantIdstringChat participant UUID
coststringMessage cost
requestIdstringInternal request tracking ID
tqrmtIdstringTemplate Quick Reply Message Tracking ID

Message lifecycle

ACCEPTED ──> SENT ──> DELIVERED ──> READ ──> PLAYED
                 └──> FAILED

PLAYED 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 terminal

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.


Session status payload

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
}
FieldTypeDescription
whatsAppNumberstringThe WhatsApp number whose session changed
statusstringDescriptive session status from WhatsApp (see status values below)
statusTypeenumSession state: connecting, success, disconnected
timestamplongUnix timestamp (milliseconds) of the status change

Status values

statusDescription
Connecting...Connection attempt in progress
ConnectedSuccessfully connected to WhatsApp
Connected (Authenticated)Connected and authenticated
Paired SuccessfullyDevice pairing completed
Paired FailedDevice pairing failed
Logged InSession logged in
Logged In SuccessfullyLogin completed
Logged OutSession logged out
Logged out from the systemLogout by system
Logging out...Logout in progress
DisconnectedSession disconnected — all messaging has stopped
Disconnected by API requestDisconnected via API call
Disconnected: Logged in on another deviceSession replaced on another device
Disconnected: QR Code timeoutQR code pairing timed out
Initializing...Session initializing
Sync Complete. You can now send messagesApp state sync finished, session ready
Syncing metadata (errors occurred)...Sync in progress with errors
No SessionNo active session exists
Disconnected status

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.

Session lifecycle

QR Code Scanned ──> connecting ──> success
                         └──> disconnected
statusTypeTrigger eventsDescription
connectingQR code scanned, pair success, connected, logged in, app state sync in progressSession is being established or reconnecting
successApp state sync completeSession is fully connected and ready
disconnectedPair failure, connection failure, disconnected, logged out, stream replacedSession has been lost or terminated
WhatsApp Lite only

Session status webhooks are only sent for WhatsApp Lite numbers. WhatsApp Cloud numbers are managed by Meta and do not emit session events.


Event webhook payloads

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

Common fields

Every event webhook includes these fields:

FieldTypeDescription
eventTypestringThe event type identifier (see below)
whatsAppNumberstringThe WhatsApp number this event relates to
timestamplongUnix timestamp (milliseconds) of the event

PHONE_QUALITY_CHANGED example

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"
}
FieldTypeValues
qualityRatingstringGREEN (high), YELLOW (medium), RED (low)
messagingLimitTierstringTIER_1K, TIER_10K, TIER_100K, TIER_UNLIMITED
channelStatusstringCONNECTED, DISCONNECTED, FLAGGED, RESTRICTED
Act on quality drops

If qualityRating drops to YELLOW or RED, review your messaging practices immediately. Continued low quality can result in Meta restricting your number.


TEMPLATE_STATUS_CHANGED example

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"
}
FieldTypeDescription
templateNamestringName of the affected template
templateStatusstringAPPROVED, REJECTED, PAUSED, DISABLED
rejectedReasonstringReason for rejection (only present when REJECTED)

TEMPLATE_QUALITY_CHANGED example

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"
}
FieldTypeDescription
templateNamestringName of the affected template
previousQualityScorestringPrevious quality: GREEN, YELLOW, RED, UNKNOWN
newQualityScorestringNew quality: GREEN, YELLOW, RED, UNKNOWN

TEMPLATE_COMPONENTS_CHANGED example

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"
}
FieldTypeDescription
templateNamestringName of the affected template
templateLanguagestringLanguage code of the template (e.g., en, af, zu)
templateElementstringComponent that changed: HEADER, BODY, FOOTER, BUTTONS

USER_PREFERENCE example

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"
}
FieldTypeDescription
waIdstringThe user's WhatsApp ID (phone number without +)
categorystringMessage category (e.g., MARKETING)
preferenceValuestringstop (opted out) or resume (opted back in)
Compliance required

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.


USER_CHANGED_NUMBER example

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"
}
FieldTypeDescription
oldNumberstringThe user's previous phone number
newNumberstringThe user's new phone number

Event webhook handler example

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');
});

Chatbot conversation payload

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.

Incoming message (customer → bot)

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

Incoming message (Customer reply to a template)

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.

Incoming message (customer quoting a message)

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.

Outgoing bot reply (bot → customer)

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?"
}

After agent handover

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 reference

FieldTypeDescription
eventIdstring (UUID)Server-generated unique event id. Use for receiver-side idempotency / deduplication
directionenumINCOMING (customer → bot) or OUTGOING (bot → customer)
timestamplongUnix timestamp (milliseconds) the event was emitted
chatParticipantIdstring (UUID)Chat participant the event belongs to
conversationStatusstringCurrent conversation status (e.g., NEW, ACTIVE)
conversationClosedbooleantrue when the conversation has been closed
conversationMissedbooleantrue when the conversation came after working hours or failed to be assigned to an agent cause the agent was offline
chatBotConversationbooleantrue while the chatbot is driving the conversation
assignedAgentIdstring (UUID)Currently assigned human agent — omitted when the bot is in control
assignedAgentNamestringCurrently assigned agent's display name (first + last) — omitted when no agent assigned
lastAssignedAgentIdstring (UUID)Previously assigned agent (set after a chat closes/reopens) — omitted if none
lastAssignedAgentNamestringPreviously assigned agent's display name — omitted if none
clientRefstringYour client reference
senderMsisdnstringSender MSISDN (customer for INCOMING, your WhatsApp number for OUTGOING)
receiverMsisdnstringReceiver MSISDN (your WhatsApp number for INCOMING, customer for OUTGOING)
messageIdstringInternal message id
ctIdstringCustom tracking id (when set on send)
messageTypestringMessage 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
messageTextstringMessage 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)
mediaUrlstringMedia URL (only present for media messages — omitted otherwise)
templateIdstring (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
templateNamestringOriginating 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.

Chatbot behavior and flags

The webhook is gated by three configuration values on WhatsAppClientWebHook:

FlagDefaultEffect
chatbotWebhookURLunsetWhen unset or blank, no chatbot webhooks fire
chatbotIncludeOutgoingfalseWhen true, also emit OUTGOING events for bot widget replies. When false, only INCOMING events fire
chatbotStopOnAgentAssignmentfalseWhen 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.

Payload version (v1 vs v2)

The chatbot webhook supports two payload shapes, selected per-webhook via the chatbotPayloadVersion field on WhatsAppClientWebHook:

VersionEffect
v1 (default; also when chatbotPayloadVersion is omitted or null)Flat payload as shown above
v2Same 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.

`v1` is deprecated

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.

Replying to the customer from a v2 webhook

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.

Example v2 incoming chatbot payload
{
  "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
}
Example v2 incoming chatbot payload — reply to a template

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
}
v2 field reference
FieldTypeDescription
teamIdstring (UUID)Team currently handling the chat. Omitted when the chat isn't routed to a team
teamNamestringDisplay name of the team currently handling the chat. Omitted when the chat isn't routed to a team
routedToAnotherTeambooleanEmitted 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
quotedMessageIdstringWhatsApp id of the message the customer quoted/replied to. Present on INCOMING events when the message is a reply/quote; omitted otherwise
quotedTextstringSnapshot of the quoted message's text, captured when the reply arrived. Present and omitted under the same conditions as quotedMessageId
senderUserIdstringCustomer'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
recipientUserIdstringCustomer's BSUID on OUTGOING events (the customer is the recipient of the bot reply). Always present on OUTGOING events
usernamestringCustomer'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.

How to enable v2

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.

Coexistence with incomingMessageURL

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.

Chatbot webhook handler example

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');
});

Handling webhooks

Incoming message handler

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);

Status update handler

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');
});

Best practices

Production recommendations
  1. Respond within 5 seconds — Return 200 OK immediately to prevent retries. Do heavy processing asynchronously.
  2. Process asynchronously — Queue incoming webhooks (Redis, RabbitMQ, SQS) for background processing rather than handling inline.
  3. Deduplicate — Use gstId or ctId to detect duplicate webhook deliveries and ensure idempotent processing.
  4. Use HTTPS only — Always use HTTPS URLs with a valid SSL certificate for your webhook endpoints.
  5. Log everything — Log all webhook payloads with timestamps for debugging delivery issues. Redact sensitive data.
  6. Monitor uptime — If your webhook endpoint is down, you'll miss events. Use health checks and uptime monitoring.
  7. Handle all content types — Incoming messages can be TEXT, MEDIA, LOCATION, BUTTON, INTERACTIVE, CONTACT, REACTION, or CAROUSEL.
  8. Handle reaction removals — A REACTION with empty text means the user removed their reaction.