# Webhooks

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](/guides/authentication) for setup and security best practices.

## Webhook types

| Type | URL field | What it does |
|  --- | --- | --- |
| [**Incoming Messages**](#incoming-message-payload) | `incomingMessageURL` | Notifies you when a customer sends a message to your WhatsApp number |
| [**Status Updates**](#status-update-payload) | `statusUpdateURL` | Tracks what happened to a message you sent ([see statuses below](#message-delivery-statuses)) |
| [**Session Status**](#session-status-payload) | `sessionStatusURL` | Tells you if your WhatsApp Lite connection is up or down ([see statuses below](#session-statuses-whatsapp-lite-only)) |
| [**Partner Events**](#event-webhook-payloads) | `eventWebhookURL` | Platform-level events from Meta ([see list below](#partner-events)) |
| [**Chatbot Conversations**](#chatbot-conversation-payload) | `chatbotWebhookURL` | Chatbot conversation events with chat participant context ([see behavior](#chatbot-behavior-and-flags)). Optional outgoing replies, optional auto-stop on agent handover |


### Message delivery statuses

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


### Session statuses (WhatsApp Lite only)

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


Disconnected Status (WhatsApp Lite only)
A `disconnected` session status means all incoming and outgoing messages have stopped. Reconnect immediately to restore service.

### Partner events

- [User preferences](#user_preference-example) — a customer stopped or resumed marketing messages
- [Template status changes](#template_status_changed-example) — a template was approved, rejected, paused, or disabled
- [Phone quality rating updates](#phone_quality_changed-example) — Meta changed your number's quality rating or messaging tier
- [Template quality changes](#template_quality_changed-example) — a template's quality score went up or down
- [Template component changes](#template_components_changed-example) — a template's content was modified
- [User number changes](#user_changed_number-example) — a customer switched to a new phone number


## Webhook scope

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.

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

```json
{
  "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**:

```json
{
  "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.

```json
{
  "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:

```json
{
  "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.

```json
{
  "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

```json
{
  "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)

```json
{
  "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

```json
{
  "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

```json
{
  "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

```json
{
  "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**.

```json
{
  "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

| 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](#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) |


#### Content types

| `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` |


### Status update payload

Sent to your `statusUpdateURL` when the delivery status of an outbound message changes.

```json
{
  "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](/guides/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 |


### Message lifecycle

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

```json
{
  "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 values

| `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 |


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

```text
QR Code Scanned ──> connecting ──> success
                         └──> disconnected
```

| `statusType` | 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 |


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:

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


#### `PHONE_QUALITY_CHANGED` example

Triggered when Meta updates the quality rating or messaging tier of your WhatsApp number.

```json
{
  "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` |


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.

```json
{
  "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`) |


#### `TEMPLATE_QUALITY_CHANGED` example

Triggered when a template's quality score changes based on user feedback and engagement metrics.

```json
{
  "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` |


#### `TEMPLATE_COMPONENTS_CHANGED` example

Triggered when a template's components are modified (e.g., after editing and resubmitting for approval).

```json
{
  "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` |


#### `USER_PREFERENCE` example

Triggered when a WhatsApp user opts out of or back into marketing messages via WhatsApp's built-in marketing preference flow.

```json
{
  "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) |


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.

```json
{
  "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 |


#### Event webhook handler example

Node.js (Express)
```javascript
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');
});
```

Python (Flask)
```python
@app.route('/webhooks/events', methods=['POST'])
def event_webhook():
    event = request.get_json()
    event_type = event.get('eventType')

    print(f"Event: {event_type}")
    print(f"Number: {event.get('whatsAppNumber')}")

    if event_type == 'PHONE_QUALITY_CHANGED':
        print(f"Quality: {event.get('qualityRating')}, Tier: {event.get('messagingLimitTier')}")
        if event.get('qualityRating') == 'RED':
            alert_team('Phone quality dropped to RED')

    elif event_type == 'TEMPLATE_STATUS_CHANGED':
        print(f"Template \"{event.get('templateName')}\" → {event.get('templateStatus')}")
        if event.get('templateStatus') == 'REJECTED':
            print(f"Reason: {event.get('rejectedReason')}")

    elif event_type == 'USER_PREFERENCE':
        print(f"User {event.get('waId')} {event.get('preferenceValue')} {event.get('category')}")
        update_marketing_preference(event.get('waId'), event.get('preferenceValue'))

    elif event_type == 'USER_CHANGED_NUMBER':
        print(f"Number change: {event.get('oldNumber')} → {event.get('newNumber')}")
        update_contact_number(event.get('oldNumber'), event.get('newNumber'))

    return 'OK', 200
```

Java (Spring Boot)
```java
@PostMapping("/webhooks/events")
public String eventWebhook(@RequestBody Map<String, Object> event) {
    String eventType = (String) event.get("eventType");
    System.out.println("Event: " + eventType);
    System.out.println("Number: " + event.get("whatsAppNumber"));

    switch (eventType) {
        case "PHONE_QUALITY_CHANGED":
            System.out.println("Quality: " + event.get("qualityRating")
                + ", Tier: " + event.get("messagingLimitTier"));
            break;
        case "TEMPLATE_STATUS_CHANGED":
            System.out.println("Template \"" + event.get("templateName")
                + "\" → " + event.get("templateStatus"));
            break;
        case "USER_PREFERENCE":
            System.out.println("User " + event.get("waId")
                + " " + event.get("preferenceValue") + " " + event.get("category"));
            // Update marketing preference
            break;
        case "USER_CHANGED_NUMBER":
            System.out.println("Number change: " + event.get("oldNumber")
                + " → " + event.get("newNumber"));
            // Update contact records
            break;
    }

    return "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)

```json
{
  "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.

```json
{
  "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.

```json
{
  "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`.

```json
{
  "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.

```json
{
  "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

| 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](#v2-field-reference).

#### Chatbot behavior and flags

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.

#### Payload version (`v1` vs `v2`)

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.

`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)](https://ecommunicate-api-docs.redocly.app/apis/omnichannel/live-chat/sendmessagev2) using the
`chatParticipantId` from the payload — set `channel` and `text`. For media, first upload the file via
[Upload media for an agent message (v2)](https://ecommunicate-api-docs.redocly.app/apis/omnichannel/live-chat/uploadmediav2) 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

```json
{
  "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`).

```json
{
  "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

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

##### How to enable `v2`

`v2` is opt-in per webhook configuration. Send `chatbotPayloadVersion: "v2"` on `POST` / `PUT /payless4messaging-service/WhatsApp/WhatsAppClientWebHook`:

```json
{
  "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

Node.js (Express)
```javascript
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');
});
```

Python (Flask)
```python
@app.route('/webhooks/chatbot', methods=['POST'])
def chatbot_event():
    event = request.get_json()

    print(f"Event {event.get('eventId')} ({event.get('direction')})")
    print(f"Participant: {event.get('chatParticipantId')}, status: {event.get('conversationStatus')}")

    if event.get('assignedAgentId'):
        print(f"Assigned to agent: {event.get('assignedAgentName')} ({event.get('assignedAgentId')})")
    else:
        print('Bot is in control')

    if event.get('direction') == 'INCOMING':
        print(f"Customer {event.get('senderMsisdn')}: {event.get('messageText')}")
    else:
        print(f"Bot reply to {event.get('receiverMsisdn')}: {event.get('messageText')}")

    return 'OK', 200
```

Java (Spring Boot)
```java
@PostMapping("/webhooks/chatbot")
public String chatbotEvent(@RequestBody Map<String, Object> event) {
    System.out.println("Event " + event.get("eventId") + " (" + event.get("direction") + ")");
    System.out.println("Participant: " + event.get("chatParticipantId")
        + ", status: " + event.get("conversationStatus"));

    if (event.get("assignedAgentId") != null) {
        System.out.println("Assigned to agent: " + event.get("assignedAgentName")
            + " (" + event.get("assignedAgentId") + ")");
    } else {
        System.out.println("Bot is in control");
    }

    if ("INCOMING".equals(event.get("direction"))) {
        System.out.println("Customer " + event.get("senderMsisdn") + ": " + event.get("messageText"));
    } else {
        System.out.println("Bot reply to " + event.get("receiverMsisdn") + ": " + event.get("messageText"));
    }

    return "OK";
}
```

## Handling webhooks

### Incoming message handler

Node.js (Express)
```javascript
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);
```

Python (Flask)
```python
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/incoming', methods=['POST'])
def incoming_message():
    message = request.get_json()
    content_type = message.get('messageContentType')

    print(f"From: {message.get('sender')} ({message.get('profileName')})")
    print(f"Type: {content_type}")

    if content_type == 'TEXT':
        print(f"Text: {message.get('text')}")
    elif content_type == 'MEDIA':
        print(f"Media: {message.get('mediaUrl')} ({message.get('fileExtension')})")
    elif content_type == 'LOCATION':
        print(f"Location: {message.get('latitude')}, {message.get('longitude')}")
    elif content_type in ('BUTTON', 'INTERACTIVE'):
        print(f"Button: {message.get('buttonText')} ({message.get('buttonPayload')})")
    elif content_type == 'REACTION':
        print(f"Reaction: {message.get('text') or '(removed)'} on {message.get('gstId')}")

    # Queue for async processing
    # message_queue.enqueue(process_message, message)

    return 'OK', 200

if __name__ == '__main__':
    app.run(port=3000)
```

Java (Spring Boot)
```java
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
public class WebhookController {

    @PostMapping("/webhooks/incoming")
    public String incomingMessage(@RequestBody Map<String, Object> message) {
        System.out.println("From: " + message.get("sender") + " (" + message.get("profileName") + ")");
        String contentType = (String) message.get("messageContentType");
        System.out.println("Type: " + contentType);

        switch (contentType) {
            case "TEXT":
                System.out.println("Text: " + message.get("text"));
                break;
            case "MEDIA":
                System.out.println("Media: " + message.get("mediaUrl"));
                break;
            case "LOCATION":
                System.out.println("Location: " + message.get("latitude") + ", " + message.get("longitude"));
                break;
            case "BUTTON":
            case "INTERACTIVE":
                System.out.println("Button: " + message.get("buttonText"));
                break;
            case "REACTION":
                System.out.println("Reaction: " + message.get("text") + " on " + message.get("gstId"));
                break;
        }

        // Queue for async processing
        return "OK";
    }
}
```

### Status update handler

Node.js (Express)
```javascript
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');
});
```

Python (Flask)
```python
@app.route('/webhooks/status', methods=['POST'])
def status_update():
    update = request.get_json()
    status = update.get('status')

    print(f"Message ID: {update.get('gstId')}")
    print(f"Status: {status}")

    if status == 'DELIVERED':
        print(f"Delivered at: {update.get('deliveryTime')}")
    elif status == 'READ':
        print(f"Read at: {update.get('readTime')}")
    elif status == 'FAILED':
        print(f"Error: {update.get('errorCodeString')} (code: {update.get('errorCode')})")

    return 'OK', 200
```

Java (Spring Boot)
```java
@PostMapping("/webhooks/status")
public String statusUpdate(@RequestBody Map<String, Object> update) {
    System.out.println("Message ID: " + update.get("gstId"));
    System.out.println("Status: " + update.get("status"));

    if ("FAILED".equals(update.get("status"))) {
        System.out.println("Error: " + update.get("errorCodeString")
            + " (code: " + update.get("errorCode") + ")");
    }

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