# Event Types

> Complete reference for all webhook event types and their payloads

Zavu sends webhook events for message lifecycle stages. There are several categories:

| Category | Events | Purpose |
|----------|--------|---------|
| **Inbound** | `conversation.new`, `message.inbound`, `message.unsupported`, `message.reaction` | Receive messages, reactions, and new contact notifications |
| **Contacts** | `contact.identity_linked` | Know when a WhatsApp contact's BSUID and phone number were tied together |
| **Outbound** | `message.queued`, `message.sent`, `message.delivered`, `message.failed` | Track your outbound message delivery |
| **Templates** | `template.status_changed` | Track WhatsApp template approval status |
| **Broadcasts** | `broadcast.status_changed` | Track broadcast delivery lifecycle |
| **Invitations** | `invitation.status_changed` | Track partner invitation status changes |
| **Sandboxes** | `sandbox.running`, `sandbox.stopped`, `sandbox.failed`, `sandbox_snapshot.ready`, `sandbox_snapshot.failed` | Know when a compute sandbox or a saved snapshot changes state |

## Event Structure

All events follow this structure:

```json
{
  "id": "evt_1705312200000_abc123",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    // Event-specific payload
  }
}
```

---

## Inbound Events

## conversation.new

Triggered when a new contact sends you their first message. This is useful for tracking new leads or customers.

```json
{
  "id": "evt_1705312200000_new123",
  "type": "conversation.new",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "phoneNumber": "+14155551234",
    "channel": "whatsapp",
    "firstMessageId": "msg_xyz789",
    "firstMessageText": "Hi, I'm interested in your product",
    "profileName": "John Doe"
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `phoneNumber` | string | New contact's phone number (E.164 format) |
| `channel` | string | Channel used: `sms` or `whatsapp` |
| `firstMessageId` | string | ID of the first message |
| `firstMessageText` | string | Content of the first message |
| `profileName` | string \| null | Contact's WhatsApp profile name. `null` for SMS. |

> **Note:**
> You'll also receive a `message.inbound` event for the same message. Use `conversation.new` specifically for new lead notifications or CRM integrations.

---

## message.inbound

Triggered when a customer sends you a message on any channel: SMS, WhatsApp, Telegram, Instagram, Messenger or email. This includes text, images, videos, audio, documents, stickers, locations, and contacts.

### Text Message Example

```json
{
  "id": "evt_1705312200000_abc123",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_xyz789",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "text",
    "text": "Hi, I have a question about my order",
    "profileName": "John Doe",
    "providerTimestamp": 1705312199000
  }
}
```

### Username Contact (BSUID) Example

WhatsApp users can adopt a username and hide their phone number. Their messages arrive through the same event, but `from` carries a **business-scoped user ID (BSUID)** instead of an E.164 phone number:

```json
{
  "id": "evt_1705312200000_bsu123",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_bsu789",
    "conversationId": "js723987cyghwqxxaxcf590qd18axd95",
    "contactId": "jd7x2k3m4n5p6q7r8s9t0abc",
    "whatsapp": { "bsuid": "US.13491208655302741918", "username": "sheena" },
    "from": "US.13491208655302741918",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "text",
    "text": "Hi, I saw your listing",
    "profileName": "Sheena",
    "providerTimestamp": 1705312199000
  }
}
```

> **Warning:**
> Do not assume `from` is a phone number, and do not use it as the key for a person: it is the phone number when WhatsApp includes it and the BSUID otherwise, so it can change for the same contact. Key on `contactId`. To reply, pass `from` back as `to` in the send API, which accepts BSUIDs directly. See [Usernames & BSUIDs](https://www.zavu.dev/docs/guides/whatsapp/usernames) for the full picture.

### Image Message Example

```json
{
  "id": "evt_1705312200000_img456",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_img789",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "image",
    "text": "Check out this product",
    "content": {
      "mediaId": "1234567890",
      "mimeType": "image/jpeg"
    },
    "profileName": "John Doe"
  }
}
```

> **Note:**
> For media messages (image, video, audio, document, sticker), the `content.mediaId` is provided initially. The media is then downloaded and stored, and subsequent reads of the message will include a `content.mediaUrl` with the permanent URL.

### Location Message Example

```json
{
  "id": "evt_1705312200000_loc789",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_loc123",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "location",
    "text": "",
    "content": {
      "latitude": 37.7749,
      "longitude": -122.4194,
      "name": "San Francisco",
      "address": "San Francisco, CA, USA"
    },
    "profileName": "John Doe"
  }
}
```

> **Note:**
> When the location answers a [location request](https://www.zavu.dev/docs/guides/whatsapp/messages/location-request) you sent, the same event carries `content.replyToMessageId` pointing at the request. There is no separate event type — match on that field to tie the coordinates back to the order, ticket or job you were asking about.

### Contact Message Example

```json
{
  "id": "evt_1705312200000_cnt456",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_cnt789",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "contact",
    "text": "",
    "content": {
      "contacts": [
        {
          "name": "Jane Smith",
          "phones": [
            { "phone": "+14155559999", "type": "MOBILE" }
          ],
          "emails": [
            { "email": "jane@example.com", "type": "WORK" }
          ]
        }
      ]
    },
    "profileName": "John Doe"
  }
}
```

### Interactive Reply Example

When a user clicks a button or selects a list item that you sent, you'll receive the reply with the button/item ID:

```json
{
  "id": "evt_1705312200000_int123",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_int456",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "text",
    "text": "Yes, I'm interested",
    "content": {
      "interactiveReply": {
        "type": "button_reply",
        "id": "btn_interested",
        "title": "Yes, I'm interested"
      }
    },
    "profileName": "John Doe"
  }
}
```

> **Note:**
> The `interactiveReply.id` is the ID you specified when sending the button or list message. Use this to identify which option the user selected.

### Reply / Quote Context Example

When a customer replies to (quotes) an earlier message, the `content` object carries the reply context so you can thread the conversation. When the quoted message exists in Zavu, you get its Zavu `replyToMessageId`, a text snippet, and its type:

```json
{
  "id": "evt_1705312300000_rep123",
  "type": "message.inbound",
  "timestamp": 1705312300000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_reply999",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "text",
    "text": "Yes, that order is correct",
    "content": {
      "replyToMessageId": "msg_original123",
      "replyToProviderMessageId": "wamid.HBgL...ORIGINAL123",
      "replyToFrom": "+13125559876",
      "replyToText": "Your order #ORD-12345 has shipped.",
      "replyToMessageType": "text"
    },
    "profileName": "John Doe"
  }
}
```

If the quoted message is not stored in Zavu (e.g. an old or unknown message), the context degrades gracefully — you still get the provider ID and sender, but `replyToMessageId`, `replyToText`, and `replyToMessageType` are omitted:

```json
{
  "id": "evt_1705312400000_rep456",
  "type": "message.inbound",
  "timestamp": 1705312400000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_reply888",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "messageType": "text",
    "text": "Replying to an old message",
    "content": {
      "replyToProviderMessageId": "wamid.HBgL...OLD_UNKNOWN",
      "replyToFrom": "+13125559876"
    },
    "profileName": "John Doe"
  }
}
```

> **Note:**
> Use `replyToMessageId` to link the reply to a message in your system. When it's absent, fall back to `replyToProviderMessageId` (the WhatsApp WAMID), which is always present on a reply.

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `messageId` | string | Unique message identifier |
| `conversationId` | string | The inbox thread this message was filed in. Always set: the webhook is sent once the message is in its thread, so the first message of a new thread carries the same id as `conversation.new`. Absent on reactions. |
| `contactId` | string \| null | The Zavu contact. Stays the same when a WhatsApp contact's `from` switches between BSUID and phone number, unless that switch reveals two contacts for one person: they are merged, and `contact.identity_linked` names the one that went away in `mergedContactId`. Absent on reactions. |
| `whatsapp` | object | WhatsApp only, when the BSUID is known: `{ bsuid, username? }`. |
| `from` | string | Customer's phone number (E.164 format), or their [BSUID](https://www.zavu.dev/docs/guides/whatsapp/usernames) (e.g. `US.13491208655302741918`) for WhatsApp contacts who adopted a username and hid their number |
| `to` | string | Your Sender's phone number |
| `channel` | string | `sms`, `whatsapp`, `telegram`, `instagram`, `email`, etc. |
| `messageType` | string | Message type: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact` |
| `text` | string | Message text or media caption (also button/list reply title for interactive messages) |
| `content` | object \| null | Additional content (for non-text messages) |
| `content.mediaId` | string | Media ID from WhatsApp (media messages) |
| `content.mimeType` | string | MIME type of the media |
| `content.filename` | string | Filename (for documents) |
| `content.latitude` | number | Latitude coordinate (location messages) |
| `content.longitude` | number | Longitude coordinate (location messages) |
| `content.name` | string | Location name (location messages) |
| `content.address` | string | Location address (location messages) |
| `content.contacts` | array | Contact cards: `{ name, phones: string[] }[]` |
| `content.interactiveReply` | object | Interactive reply details (when user clicks a button or list item) |
| `content.interactiveReply.type` | string | `button_reply` or `list_reply` |
| `content.interactiveReply.id` | string | ID of the button/list item clicked |
| `content.interactiveReply.title` | string | Title of the button/list item clicked |
| `content.replyToMessageId` | string | Zavu ID of the quoted message. Only present when the quoted message exists in Zavu. |
| `content.replyToProviderMessageId` | string | Provider message ID (WhatsApp WAMID) of the quoted message. Always present on a reply. |
| `content.replyToFrom` | string | Sender of the quoted message (E.164 format). |
| `content.replyToText` | string | Truncated snippet of the quoted message text (≤200 chars). Empty when the quoted message has no text (e.g. media). |
| `content.replyToMessageType` | string | Type of the quoted message (`text`, `image`, `video`, etc.). |
| `profileName` | string \| null | Sender's WhatsApp profile name. `null` for SMS. |
| `providerTimestamp` | number \| null | Provider's original receive time (Unix ms) — when the channel received the message from the contact. Set for WhatsApp, Telegram, Instagram, and Messenger; `null` for SMS and email. Compare against the top-level `timestamp` (dispatch time) to detect delayed deliveries. |
| `heldAt` | number | Present only on a message that arrived while the monthly allowance was spent and was delivered later, once the team paid: when it was held (Unix ms). See [sending limits](https://www.zavu.dev/docs/concepts/sending-limits). Absent on every other message. |

**Note:**
The top-level `timestamp` is when **Zavu dispatched the webhook**, not when the contact sent the message. If a channel delays delivery, these can differ by minutes. Use `data.providerTimestamp` to measure the real delay and skip stale messages:

```typescript
const delaySeconds = (event.timestamp - event.data.providerTimestamp) / 1000
if (event.data.providerTimestamp && delaySeconds > 120) {
  // Message arrived more than 2 minutes late — likely stale, skip it.
  return
}
```

A message carrying `data.heldAt` was held at the allowance and delivered once you paid, so it can be days old. The same check applies; `heldAt` tells you why it is late.

### Example Handler

```typescript
async function handleInboundMessage(data) {
  const { from, text, channel } = data;

  // Store the message
  await db.messages.create({
    from,
    text,
    channel,
    receivedAt: new Date()
  });

  // Send auto-reply if needed
  if (text.toLowerCase().includes("help")) {
    await zavu.messages.send({
      to: from,
      text: "Thanks for reaching out! A support agent will contact you shortly."
    });
  }
}
```

---

## message.unsupported

Triggered when a customer sends a message type that cannot be fully represented on the channel. On **WhatsApp Cloud API**, this includes polls, deleted messages, ephemeral messages, and some third-party stickers.

```json
{
  "id": "evt_1705312200000_unsup123",
  "type": "message.unsupported",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_xyz789",
    "from": "+14155551234",
    "to": "+13125559876",
    "channel": "whatsapp",
    "timestamp": 1705312200000
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `messageId` | string | Unique message identifier |
| `conversationId` | string | The inbox thread this message was filed in. Always set: the webhook is sent once the message is in its thread, so the first message of a new thread carries the same id as `conversation.new`. Absent on reactions. |
| `contactId` | string \| null | The Zavu contact. Stays the same when a WhatsApp contact's `from` switches between BSUID and phone number, unless that switch reveals two contacts for one person: they are merged, and `contact.identity_linked` names the one that went away in `mergedContactId`. Absent on reactions. |
| `whatsapp` | object | WhatsApp only, when the BSUID is known: `{ bsuid, username? }`. |
| `from` | string | Customer's phone number (E.164 format), or their [BSUID](https://www.zavu.dev/docs/guides/whatsapp/usernames) (e.g. `US.13491208655302741918`) for WhatsApp contacts who adopted a username and hid their number |
| `to` | string | Your Sender's phone number |
| `channel` | string | Always `whatsapp` for unsupported messages |
| `timestamp` | number | Unix timestamp when message was received |

> **Note:**
> On WhatsApp Cloud API, unsupported types typically include:
> - **Polls** — Cloud API does not expose poll content
> - **Deleted messages** — messages deleted by the sender
> - **Ephemeral messages** — disappearing messages that Cloud API does not expose
> - **Third-party stickers** — stickers not meeting WhatsApp specifications
>
> These Cloud API limitations are from Meta, not Zavu.

### Example Handler

```typescript
async function handleUnsupportedMessage(data) {
  const { from, messageId } = data;

  // Log unsupported message for analytics
  await db.unsupportedMessages.create({
    messageId,
    from,
    receivedAt: new Date()
  });

  // Optionally notify the customer
  await zavu.messages.send({
    to: from,
    text: "We received your message, but this message type is not supported. Please send a text message instead."
  });
}
```

---

## message.reaction

Triggered when a customer reacts to a message with an emoji (WhatsApp only). This event is sent when a reaction is added or removed from a message.

```json
{
  "id": "evt_1705312200000_react123",
  "type": "message.reaction",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_xyz789",
    "providerMessageId": "wamid.HBgLMTU1NT...",
    "reaction": {
      "emoji": "👍",
      "from": "+14155551234",
      "timestamp": 1705312200000,
      "action": "added"
    }
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `messageId` | string | ID of the message that was reacted to |
| `providerMessageId` | string | WhatsApp message ID |
| `reaction.emoji` | string | The emoji used for the reaction |
| `reaction.from` | string | Phone number of the person who reacted |
| `reaction.timestamp` | number | Unix timestamp of the reaction |
| `reaction.action` | string | `added` when reaction is added, `removed` when reaction is removed |

> **Note:**
> When a user removes a reaction, `action` will be `removed` and `emoji` will be an empty string.

### Example Handler

```typescript
async function handleReaction(data) {
  const { messageId, reaction } = data;
  const { emoji, from, action } = reaction;

  if (action === "added") {
    // Store the reaction
    await db.reactions.create({
      messageId,
      emoji,
      from,
      createdAt: new Date()
    });

    // Track engagement metrics
    await analytics.track("message_reaction", {
      messageId,
      emoji,
    });
  } else if (action === "removed") {
    // Remove the reaction
    await db.reactions.delete({
      messageId,
      from,
    });
  }
}
```

```python
async def handle_reaction(data):
    message_id = data["messageId"]
    reaction = data["reaction"]
    emoji = reaction["emoji"]
    from_number = reaction["from"]
    action = reaction["action"]

    if action == "added":
        # Store the reaction
        await db.reactions.create(
            message_id=message_id,
            emoji=emoji,
            from_number=from_number,
            created_at=datetime.now()
        )

        # Track engagement metrics
        await analytics.track("message_reaction", {
            "message_id": message_id,
            "emoji": emoji
        })
    elif action == "removed":
        # Remove the reaction
        await db.reactions.delete(
            message_id=message_id,
            from_number=from_number
        )
```

```ruby
def handle_reaction(data)
  message_id = data["messageId"]
  reaction = data["reaction"]
  emoji = reaction["emoji"]
  from_number = reaction["from"]
  action = reaction["action"]

  if action == "added"
    # Store the reaction
    Reaction.create!(
      message_id: message_id,
      emoji: emoji,
      from_number: from_number,
      created_at: Time.now
    )

    # Track engagement metrics
    Analytics.track("message_reaction", {
      message_id: message_id,
      emoji: emoji
    })
  elsif action == "removed"
    # Remove the reaction
    Reaction.where(message_id: message_id, from_number: from_number).destroy_all
  end
end
```

```go
func handleReaction(data map[string]interface{}) {
	messageID := data["messageId"].(string)
	reaction := data["reaction"].(map[string]interface{})
	emoji := reaction["emoji"].(string)
	from := reaction["from"].(string)
	action := reaction["action"].(string)

	if action == "added" {
		// Store the reaction
		db.Reactions.Create(messageID, emoji, from)

		// Track engagement metrics
		analytics.Track("message_reaction", map[string]string{
			"messageId": messageID,
			"emoji":     emoji,
		})
	} else if action == "removed" {
		// Remove the reaction
		db.Reactions.Delete(messageID, from)
	}
}
```

```php
<?php
function handleReaction(array $data): void {
    $messageId = $data['messageId'];
    $reaction = $data['reaction'];
    $emoji = $reaction['emoji'];
    $from = $reaction['from'];
    $action = $reaction['action'];

    if ($action === 'added') {
        // Store the reaction
        Reaction::create([
            'message_id' => $messageId,
            'emoji' => $emoji,
            'from_number' => $from,
            'created_at' => now(),
        ]);

        // Track engagement metrics
        Analytics::track('message_reaction', [
            'message_id' => $messageId,
            'emoji' => $emoji,
        ]);
    } elseif ($action === 'removed') {
        // Remove the reaction
        Reaction::where('message_id', $messageId)
            ->where('from_number', $from)
            ->delete();
    }
}
```

---

## contact.identity_linked

Fired when a WhatsApp contact known by its business-scoped user ID (BSUID) is tied to a phone number, a contact known by phone receives a BSUID, or a contact changes their number. It fires once per link, not on every message. Subscribe to it on the sender's webhook.

```json
{
  "id": "evt_1705312300000_def456",
  "type": "contact.identity_linked",
  "timestamp": 1705312300000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "contactId": "jd7x2k3m4n5p6q7r8s9t0abc",
    "bsuid": "US.13491208655302741918",
    "phoneNumber": "+13125551234",
    "reason": "provider_reported"
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `contactId` | string | The Zavu contact. If two contacts were merged, the one that remains. |
| `mergedContactId` | string | Only when two contacts were merged: the contact that was folded into `contactId`. Move anything you stored under it to `contactId`. |
| `bsuid` | string \| null | The contact's business-scoped user ID. `null` only on `phone_changed`, for a contact WhatsApp never reported a BSUID for |
| `phoneNumber` | string | The contact's phone number (E.164 format) |
| `reason` | string | `provider_reported` (WhatsApp sent phone and BSUID together on an inbound message), `contact_shared` (the contact answered a `request_contact_info` message), `phone_changed` (the contact changed their number), or `bsuid_changed` (WhatsApp reported a new BSUID for the same number) |
| `previousPhoneNumber` | string | Only with `phone_changed`: the number the contact had before |
| `previousBsuid` | string | A BSUID you may have stored that no longer identifies this contact: the one it had before, or the merged contact's when the remaining contact keeps its own |

After this event, `from` on `message.inbound` is still the BSUID whenever WhatsApp leaves the phone number out of a message. Use `contactId` to recognise the person. See [Usernames & BSUIDs](https://www.zavu.dev/docs/guides/whatsapp/usernames).

---

## Outbound Events

These events help you track the delivery status of messages you send. Subscribe to these if you need delivery confirmations or failure notifications.

## message.queued

Triggered when your message is accepted and queued for delivery.

```json
{
  "id": "evt_1705312200000_abc456",
  "type": "message.queued",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_abc123",
    "to": "+14155551234",
    "channel": "sms",
    "status": "queued"
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `messageId` | string | Your message identifier |
| `to` | string | Recipient's phone number |
| `channel` | string | `sms` or `whatsapp` |
| `status` | string | Always `queued` for this event |

---

## message.sent

Triggered when your outbound message is successfully accepted by the carrier or Meta. This confirms the message has left Zavu's systems and is being processed by the provider.

```json
{
  "id": "evt_1705312200000_def456",
  "type": "message.sent",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_abc123",
    "to": "+14155551234",
    "channel": "sms",
    "status": "sent"
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `messageId` | string | Your message identifier |
| `to` | string | Recipient's phone number |
| `channel` | string | `sms` or `whatsapp` |
| `status` | string | Always `sent` for this event |

### WhatsApp Business App Echo (Coexistence)

When using WhatsApp in coexistence mode, messages sent directly from the WhatsApp Business App also trigger `message.sent` events. These events include the full message content so you can sync outbound messages that were not sent through the Zavu API.

You can distinguish these from regular status updates by checking the `source` field.

```json
{
  "id": "evt_1705312200000_echo456",
  "type": "message.sent",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_echo789",
    "from": "+13125559876",
    "to": "+14155551234",
    "channel": "whatsapp",
    "messageType": "text",
    "status": "delivered",
    "text": "Hi! Thanks for reaching out. We'll get back to you shortly.",
    "source": "whatsapp_business_app",
    "direction": "outbound"
  }
}
```

#### Additional Fields (Coexistence Only)

| Field | Type | Description |
|-------|------|-------------|
| `from` | string | Your WhatsApp Business phone number |
| `messageType` | string | Message type: `text`, `image`, `video`, etc. |
| `text` | string | Message content or caption |
| `source` | string | Always `whatsapp_business_app` for echo messages |
| `direction` | string | Always `outbound` for echo messages |

> **Note:**
> When you receive a `message.sent` event with `source: "whatsapp_business_app"`, you should create a new outbound message record in your system rather than treating it as a status update. These messages were sent outside of Zavu's API and have their own unique `messageId`.

---

## message.delivered

Triggered when your message is confirmed delivered to the recipient's device.

```json
{
  "id": "evt_1705312200000_ghi789",
  "type": "message.delivered",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_abc123",
    "to": "+14155551234",
    "channel": "whatsapp",
    "status": "delivered"
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `messageId` | string | Your message identifier |
| `to` | string | Recipient's phone number |
| `channel` | string | `sms` or `whatsapp` |
| `status` | string | Always `delivered` for this event |

> **Note:**
> Delivery confirmation availability depends on the carrier and channel. WhatsApp provides reliable delivery receipts, while SMS delivery confirmations vary by carrier.

---

## message.failed

Triggered when message delivery fails.

```json
{
  "id": "evt_1705312200000_jkl012",
  "type": "message.failed",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "messageId": "msg_abc123",
    "to": "+14155551234",
    "channel": "sms",
    "status": "failed",
    "errorCode": "30003",
    "errorMessage": "Unreachable destination handset"
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `messageId` | string | Your message identifier |
| `to` | string | Recipient's phone number |
| `channel` | string | `sms` or `whatsapp` |
| `status` | string | Always `failed` for this event |
| `errorCode` | string | Error code from carrier/provider |
| `errorMessage` | string | Human-readable error description |

### Common Error Codes

| Code | Description | Suggested Action |
|------|-------------|------------------|
| `30003` | Unreachable destination | Number may be invalid or disconnected |
| `30005` | Unknown destination | Verify the phone number format |
| `30006` | Landline not supported | SMS cannot be sent to landlines |
| `30007` | Carrier rejected | Message blocked by carrier |
| `21610` | Unsubscribed recipient | User opted out of messages |

### Example Handler

```typescript
async function handleDeliveryFailure(data) {
  const { messageId, errorCode, errorMessage } = data;

  // Log the failure
  await db.messages.update(messageId, {
    status: "failed",
    errorCode,
    errorMessage,
    failedAt: new Date()
  });

  // Alert your team for critical failures
  if (["30007", "21610"].includes(errorCode)) {
    await alertTeam({
      type: "message_blocked",
      messageId,
      reason: errorMessage
    });
  }
}
```

```python
async def handle_delivery_failure(data):
    message_id = data["messageId"]
    error_code = data["errorCode"]
    error_message = data["errorMessage"]

    # Log the failure
    await db.messages.update(message_id, {
        "status": "failed",
        "error_code": error_code,
        "error_message": error_message,
        "failed_at": datetime.now()
    })

    # Alert your team for critical failures
    if error_code in ["30007", "21610"]:
        await alert_team({
            "type": "message_blocked",
            "message_id": message_id,
            "reason": error_message
        })
```

```ruby
def handle_delivery_failure(data)
  message_id = data["messageId"]
  error_code = data["errorCode"]
  error_message = data["errorMessage"]

  # Log the failure
  Message.find(message_id).update!(
    status: "failed",
    error_code: error_code,
    error_message: error_message,
    failed_at: Time.now
  )

  # Alert your team for critical failures
  if %w[30007 21610].include?(error_code)
    alert_team(
      type: "message_blocked",
      message_id: message_id,
      reason: error_message
    )
  end
end
```

```go
func handleDeliveryFailure(data map[string]interface{}) {
	messageID := data["messageId"].(string)
	errorCode := data["errorCode"].(string)
	errorMessage := data["errorMessage"].(string)

	// Log the failure
	db.Messages.Update(messageID, map[string]interface{}{
		"status":       "failed",
		"errorCode":    errorCode,
		"errorMessage": errorMessage,
	})

	// Alert your team for critical failures
	if errorCode == "30007" || errorCode == "21610" {
		alertTeam(map[string]string{
			"type":      "message_blocked",
			"messageId": messageID,
			"reason":    errorMessage,
		})
	}
}
```

```php
<?php
function handleDeliveryFailure(array $data): void {
    $messageId = $data['messageId'];
    $errorCode = $data['errorCode'];
    $errorMessage = $data['errorMessage'];

    // Log the failure
    Message::where('id', $messageId)->update([
        'status' => 'failed',
        'error_code' => $errorCode,
        'error_message' => $errorMessage,
        'failed_at' => now(),
    ]);

    // Alert your team for critical failures
    if (in_array($errorCode, ['30007', '21610'])) {
        alertTeam([
            'type' => 'message_blocked',
            'message_id' => $messageId,
            'reason' => $errorMessage,
        ]);
    }
}
```

---

## Template Events

## template.status_changed

Triggered when a WhatsApp template's approval status changes. This is useful for tracking when templates are approved, rejected, or disabled by Meta.

```json
{
  "id": "evt_1705312200000_tmpl123",
  "type": "template.status_changed",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "templateId": "tmpl_xyz789",
    "templateName": "order_confirmation",
    "language": "en",
    "previousStatus": "pending",
    "currentStatus": "approved",
    "rejectionReason": null
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `templateId` | string | Template identifier |
| `templateName` | string | Template name |
| `language` | string | Template language code |
| `previousStatus` | string | Status before the change |
| `currentStatus` | string | New status: `approved`, `rejected`, `pending`, `disabled` |
| `rejectionReason` | string \| null | Reason for rejection (only present when `currentStatus` is `rejected`) |

### Status Values

| Status | Description |
|--------|-------------|
| `draft` | Template created locally, not yet submitted |
| `pending` | Template submitted to Meta, awaiting review |
| `approved` | Template approved and ready to use |
| `rejected` | Template rejected by Meta |
| `disabled` | Template was approved but later disabled by Meta |

### Example Handler

```typescript
async function handleTemplateStatusChange(data) {
  const { templateId, templateName, currentStatus, rejectionReason } = data;

  // Update template status in your database
  await db.templates.update(templateId, {
    status: currentStatus,
    rejectionReason,
    updatedAt: new Date()
  });

  if (currentStatus === "approved") {
    await notifyTeam({
      message: `Template "${templateName}" has been approved!`,
      type: "success"
    });
  } else if (currentStatus === "rejected") {
    await notifyTeam({
      message: `Template "${templateName}" was rejected: ${rejectionReason}`,
      type: "error"
    });
  }
}
```

```python
async def handle_template_status_change(data):
    template_id = data["templateId"]
    template_name = data["templateName"]
    current_status = data["currentStatus"]
    rejection_reason = data.get("rejectionReason")

    # Update template status in your database
    await db.templates.update(template_id, {
        "status": current_status,
        "rejection_reason": rejection_reason,
        "updated_at": datetime.now()
    })

    if current_status == "approved":
        await notify_team({
            "message": f'Template "{template_name}" has been approved!',
            "type": "success"
        })
    elif current_status == "rejected":
        await notify_team({
            "message": f'Template "{template_name}" was rejected: {rejection_reason}',
            "type": "error"
        })
```

```ruby
def handle_template_status_change(data)
  template_id = data["templateId"]
  template_name = data["templateName"]
  current_status = data["currentStatus"]
  rejection_reason = data["rejectionReason"]

  # Update template status in your database
  Template.find(template_id).update!(
    status: current_status,
    rejection_reason: rejection_reason,
    updated_at: Time.now
  )

  if current_status == "approved"
    notify_team(
      message: "Template \"#{template_name}\" has been approved!",
      type: "success"
    )
  elsif current_status == "rejected"
    notify_team(
      message: "Template \"#{template_name}\" was rejected: #{rejection_reason}",
      type: "error"
    )
  end
end
```

```go
func handleTemplateStatusChange(data map[string]interface{}) {
	templateID := data["templateId"].(string)
	templateName := data["templateName"].(string)
	currentStatus := data["currentStatus"].(string)
	rejectionReason, _ := data["rejectionReason"].(string)

	// Update template status in your database
	db.Templates.Update(templateID, map[string]interface{}{
		"status":          currentStatus,
		"rejectionReason": rejectionReason,
	})

	if currentStatus == "approved" {
		notifyTeam(map[string]string{
			"message": fmt.Sprintf("Template \"%s\" has been approved!", templateName),
			"type":    "success",
		})
	} else if currentStatus == "rejected" {
		notifyTeam(map[string]string{
			"message": fmt.Sprintf("Template \"%s\" was rejected: %s", templateName, rejectionReason),
			"type":    "error",
		})
	}
}
```

```php
<?php
function handleTemplateStatusChange(array $data): void {
    $templateId = $data['templateId'];
    $templateName = $data['templateName'];
    $currentStatus = $data['currentStatus'];
    $rejectionReason = $data['rejectionReason'] ?? null;

    // Update template status in your database
    Template::where('id', $templateId)->update([
        'status' => $currentStatus,
        'rejection_reason' => $rejectionReason,
        'updated_at' => now(),
    ]);

    if ($currentStatus === 'approved') {
        notifyTeam([
            'message' => "Template \"{$templateName}\" has been approved!",
            'type' => 'success',
        ]);
    } elseif ($currentStatus === 'rejected') {
        notifyTeam([
            'message' => "Template \"{$templateName}\" was rejected: {$rejectionReason}",
            'type' => 'error',
        ]);
    }
}
```

---

## Partner Invitation Events

## invitation.status_changed

Triggered when a partner invitation changes status. Use it to follow a client through onboarding without polling.

It is delivered to the **project webhook** of the project that created the invitation. If you create invitations with a sub-account's API key, configure the webhook on that sub-account: a parent project does not receive its sub-accounts' events. If that project's default sender has a webhook subscribed to this event, it receives a copy too, with a top-level `senderId`.

```json
{
  "id": "evt_1705312200000_inv123",
  "type": "invitation.status_changed",
  "timestamp": 1705312200000,
  "projectId": "prj_xyz789",
  "data": {
    "invitationId": "inv_xyz789",
    "clientName": "Acme Corp",
    "clientEmail": "contact@acme.com",
    "connectionType": "whatsapp_waba",
    "previousStatus": "in_progress",
    "currentStatus": "completed",
    "senderId": "snd_newclient123",
    "wabaAccountId": "waba_abc456",
    "connectedAccount": {
      "channel": "whatsapp",
      "id": "109876543210",
      "name": "Acme Corp"
    }
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `invitationId` | string | Invitation identifier |
| `clientName` | string \| null | Client's business name |
| `clientEmail` | string \| null | Client's email address |
| `connectionType` | string | `whatsapp_waba` or `messenger` |
| `previousStatus` | string | Status before the change |
| `currentStatus` | string | New status: `in_progress`, `completed`, `failed`, `cancelled`, or `pending` |
| `failureReason` | string | Only on `failed`. Stable code such as `fb_cancelled`, `fb_not_authorized` or `signup_abandoned` |
| `senderId` | string | Only on `completed`. The sender created for this client |
| `wabaAccountId` | string | Only on `completed` WhatsApp invitations. The WhatsApp Business Account ID |
| `connectedAccount` | object \| null | Only on `completed`. The account that was linked: `channel`, `id`, `name` |

### When It Fires

| Transition | Cause |
|------------|-------|
| `pending` or `failed` to `in_progress` | The client opened the link and started the connection |
| `in_progress` to `completed` | The connection finished and the sender was created |
| `in_progress` to `failed` | The client closed Meta's dialog, denied a permission, or did not finish within an hour |
| any open status to `cancelled` | You cancelled the invitation |
| `failed` or `cancelled` to `pending` | You resent the invitation from the dashboard, which issues a new link |

A change to the same status sends nothing, so retrying a step never produces a duplicate event.

> **Note:**
> `expired` is never sent. A `pending` invitation past its expiry date reads as `expired` in `GET /v1/invitations`, but nothing changes when that moment passes, so there is no event to deliver. Check `expiresAt` if you need to act on expiry.

### Example Handler

```typescript
async function handleInvitationStatusChanged(data) {
  const { invitationId, clientName, currentStatus, senderId } = data;

  if (currentStatus === "completed") {
    // Client successfully onboarded
    await notifyTeam({
      message: `${clientName} has connected their WhatsApp!`,
      senderId,
    });

    // Create templates for the new client
    await createDefaultTemplates(senderId);
  } else if (currentStatus === "in_progress") {
    // Client started the process
    console.log(`${clientName} is completing WhatsApp setup`);
  } else if (currentStatus === "failed") {
    // The link still works: the client can retry it
    console.log(`${clientName} did not finish: ${data.failureReason}`);
  }
}
```

```python
async def handle_invitation_status_changed(data):
    invitation_id = data["invitationId"]
    client_name = data["clientName"]
    current_status = data["currentStatus"]
    sender_id = data.get("senderId")

    if current_status == "completed":
        # Client successfully onboarded
        await notify_team({
            "message": f"{client_name} has connected their WhatsApp!",
            "sender_id": sender_id
        })

        # Create templates for the new client
        await create_default_templates(sender_id)
    elif current_status == "in_progress":
        # Client started the process
        print(f"{client_name} is completing WhatsApp setup")
```

```ruby
def handle_invitation_status_changed(data)
  invitation_id = data["invitationId"]
  client_name = data["clientName"]
  current_status = data["currentStatus"]
  sender_id = data["senderId"]

  if current_status == "completed"
    # Client successfully onboarded
    notify_team(
      message: "#{client_name} has connected their WhatsApp!",
      sender_id: sender_id
    )

    # Create templates for the new client
    create_default_templates(sender_id)
  elsif current_status == "in_progress"
    # Client started the process
    puts "#{client_name} is completing WhatsApp setup"
  end
end
```

```go
func handleInvitationStatusChanged(data map[string]interface{}) {
	clientName := data["clientName"].(string)
	currentStatus := data["currentStatus"].(string)
	senderID, _ := data["senderId"].(string)

	if currentStatus == "completed" {
		// Client successfully onboarded
		notifyTeam(map[string]string{
			"message":  fmt.Sprintf("%s has connected their WhatsApp!", clientName),
			"senderId": senderID,
		})

		// Create templates for the new client
		createDefaultTemplates(senderID)
	} else if currentStatus == "in_progress" {
		// Client started the process
		fmt.Printf("%s is completing WhatsApp setup\n", clientName)
	}
}
```

```php
<?php
function handleInvitationStatusChanged(array $data): void {
    $clientName = $data['clientName'];
    $currentStatus = $data['currentStatus'];
    $senderId = $data['senderId'] ?? null;

    if ($currentStatus === 'completed') {
        // Client successfully onboarded
        notifyTeam([
            'message' => "{$clientName} has connected their WhatsApp!",
            'sender_id' => $senderId,
        ]);

        // Create templates for the new client
        createDefaultTemplates($senderId);
    } elseif ($currentStatus === 'in_progress') {
        // Client started the process
        echo "{$clientName} is completing WhatsApp setup\n";
    }
}
```

---

## Broadcast Events

## broadcast.status_changed

Triggered when a broadcast changes status. This is a **project-level event** — configure it in your project webhook settings.

```json
{
  "id": "evt_1705312200000_brd123",
  "type": "broadcast.status_changed",
  "timestamp": 1705312200000,
  "projectId": "prj_xyz789",
  "data": {
    "broadcastId": "brd_abc123",
    "name": "Black Friday Campaign",
    "status": "approved",
    "previousStatus": "pending_review",
    "channel": "sms",
    "totalContacts": 5000
  }
}
```

### Data Fields

| Field | Type | Description |
|-------|------|-------------|
| `broadcastId` | string | Broadcast identifier |
| `name` | string | Broadcast campaign name |
| `status` | string | New status after the change |
| `previousStatus` | string | Status before the change |
| `channel` | string | Broadcast channel (`sms`, `whatsapp`, `email`, etc.) |
| `totalContacts` | number | Total contacts in the broadcast |

### Status Flow

```
draft → pending_review → approved → sending → completed
              ↓
          rejected → escalated → rejected_final
              ↑
         (edit + retry)
```

| Status | Description |
|--------|-------------|
| `draft` | Initial state, adding contacts |
| `pending_review` | AI content review in progress |
| `approved` | Review passed, ready to send |
| `rejected` | Content rejected — edit and retry |
| `escalated` | Sent to human review |
| `rejected_final` | Rejected by human review (cannot appeal) |
| `sending` | Messages being delivered |
| `completed` | All messages processed |
| `cancelled` | Broadcast stopped |

### Example Handler

```typescript
async function handleBroadcastStatusChanged(data) {
  const { broadcastId, name, status, previousStatus } = data;

  switch (status) {
    case "approved":
      console.log(`Broadcast "${name}" approved — will begin sending`);
      break;
    case "rejected":
      console.log(`Broadcast "${name}" rejected — edit content and retry`);
      await notifyTeam({ message: `Broadcast rejected: ${name}` });
      break;
    case "completed":
      console.log(`Broadcast "${name}" completed!`);
      await generateReport(broadcastId);
      break;
    case "cancelled":
      console.log(`Broadcast "${name}" was cancelled`);
      break;
  }
}
```

```python
async def handle_broadcast_status_changed(data):
    broadcast_id = data["broadcastId"]
    name = data["name"]
    status = data["status"]

    if status == "approved":
        print(f'Broadcast "{name}" approved — will begin sending')
    elif status == "rejected":
        print(f'Broadcast "{name}" rejected — edit content and retry')
        await notify_team(f"Broadcast rejected: {name}")
    elif status == "completed":
        print(f'Broadcast "{name}" completed!')
        await generate_report(broadcast_id)
    elif status == "cancelled":
        print(f'Broadcast "{name}" was cancelled')
```

---

## Sandbox Events

Compute sandbox events go to the **project webhook** (`POST /v1/invitations/webhook`) of the sandbox's project. Each one carries, in `data`, the object the matching `GET` returns, read after the change: `data.sandbox` for the `sandbox.*` events and `data.snapshot` for the `sandbox_snapshot.*` ones.

```json
{
  "id": "evt_1731240000000_k3m4n5p",
  "type": "sandbox.stopped",
  "timestamp": 1731240000000,
  "projectId": "prj_xyz789",
  "data": {
    "sandbox": {
      "id": "kh7d2x9q4m1p8r3s5t6v0wabc",
      "name": "api-preview",
      "status": "stopped",
      "size": "default",
      "timeoutSeconds": 1800,
      "ephemeral": false,
      "metadata": { "job": "build-1042" },
      "startedAt": "2026-11-10T12:00:00.000Z",
      "stoppedAt": "2026-11-10T12:30:00.000Z",
      "expiresAt": "2026-11-24T12:30:00.000Z",
      "stopReason": "timeout",
      "costTotal": 0.0325,
      "failureReason": null,
      "forkedFromSandboxId": null,
      "fromSnapshotId": null,
      "createdAt": "2026-11-10T12:00:00.000Z",
      "updatedAt": "2026-11-10T12:30:05.000Z"
    }
  }
}
```

| Event | When it fires |
|-------|---------------|
| `sandbox.running` | The machine finished starting, after a create, resume or copy. Machine time is billed from the start. |
| `sandbox.stopped` | The machine is down and the sandbox reads `stopped`, so it can be resumed now. Not sent at `stopping`, and not sent for a sandbox deleted while running. |
| `sandbox.failed` | It could not start, or failed on its host. `failureReason` says which. A failed resume is not sent: the sandbox stays `stopped` and the resume call already returned the error. |
| `sandbox_snapshot.ready` | A saved snapshot finished saving. Start sandboxes from it with `from`. |
| `sandbox_snapshot.failed` | The save did not finish. Its first day is refunded, so `costTotal` reads `0`. |

### stopReason

| Value | Meaning |
|-------|---------|
| `requested` | You stopped it, from the API or the dashboard. |
| `timeout` | Its `timeoutSeconds` passed. |
| `insufficient_balance` | The balance no longer covered 15 more minutes. |
| `spend_cap_reached` | The monthly spend cap no longer covered 15 more minutes. |
| `plan` | The team left its paid plan. |
| `provider` | It ended on its host. |

Deliveries can repeat or arrive out of order. Deduplicate by `id`, and trust `data.sandbox.status` over the order events arrive in.

```typescript
async function handleSandboxEvent(type: string, data: any) {
  if (type === "sandbox.stopped" && data.sandbox.stopReason !== "requested") {
    // The platform stopped it: the job running on it did not finish.
    await markJobInterrupted(data.sandbox.metadata.job, data.sandbox.stopReason);
  }
  if (type === "sandbox.failed") {
    await markJobFailed(data.sandbox.metadata.job, data.sandbox.failureReason);
  }
}
```

---

## Best Practices

### Idempotency

Webhook deliveries may be retried, so your handler should be idempotent:

```javascript
async function handleEvent(event) {
  // Check if we've already processed this event
  const existing = await db.processedEvents.find(event.id);
  if (existing) {
    console.log(`Event ${event.id} already processed, skipping`);
    return;
  }

  // Process the event
  await processEventLogic(event);

  // Mark as processed
  await db.processedEvents.create({ eventId: event.id });
}
```

### Error Handling

Always wrap your handlers in try-catch to prevent unhandled exceptions:

```javascript
app.post('/webhooks/zavu', async (req, res) => {
  try {
    await handleEvent(req.body);
    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook handling error:', error);
    // Return 200 to prevent retries if the error is not transient
    res.status(200).send('OK');
  }
});
```

## Next Steps

- [Security](https://www.zavu.dev/docs/guides/receiving-messages/security) - Verify webhook signatures
- [Webhooks](https://www.zavu.dev/docs/guides/receiving-messages/webhooks) - Configure your endpoints
