> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zavu.dev/llms.txt
> Use this file to discover all available pages before exploring further.

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

## Event Structure

All events follow this structure:

```json theme={null}
{
  "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 theme={null}
{
  "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.
</Note>

***

## message.inbound

Triggered when a customer sends you a message via SMS or WhatsApp. This includes text messages, images, videos, audio, documents, and stickers.

### Text Message Example

```json theme={null}
{
  "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"
  }
}
```

### Image Message Example

```json theme={null}
{
  "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.
</Note>

### Location Message Example

```json theme={null}
{
  "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"
  }
}
```

### Contact Message Example

```json theme={null}
{
  "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 theme={null}
{
  "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.
</Note>

### Data Fields

| Field                            | Type           | Description                                                                                   |
| -------------------------------- | -------------- | --------------------------------------------------------------------------------------------- |
| `messageId`                      | string         | Unique message identifier                                                                     |
| `from`                           | string         | Customer's phone number (E.164 format)                                                        |
| `to`                             | string         | Your Sender's phone number                                                                    |
| `channel`                        | string         | `sms` or `whatsapp`                                                                           |
| `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 (contact messages)                                                              |
| `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                                                         |
| `profileName`                    | string \| null | Sender's WhatsApp profile name. `null` for SMS.                                               |

### Example Handler

<CodeGroup>
  ```typescript TypeScript theme={null}
  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."
      });
    }
  }
  ```

  ```python Python theme={null}
  async def handle_inbound_message(data):
      from_number = data["from"]
      text = data["text"]
      channel = data["channel"]

      # Store the message
      await db.messages.create(
          from_number=from_number,
          text=text,
          channel=channel,
          received_at=datetime.now()
      )

      # Send auto-reply if needed
      if "help" in text.lower():
          await zavu.messages.send(
              to=from_number,
              text="Thanks for reaching out! A support agent will contact you shortly."
          )
  ```

  ```ruby Ruby theme={null}
  def handle_inbound_message(data)
    from_number = data["from"]
    text = data["text"]
    channel = data["channel"]

    # Store the message
    Message.create!(
      from_number: from_number,
      text: text,
      channel: channel,
      received_at: Time.now
    )

    # Send auto-reply if needed
    if text.downcase.include?("help")
      client.messages.send(
        to: from_number,
        text: "Thanks for reaching out! A support agent will contact you shortly."
      )
    end
  end
  ```

  ```go Go theme={null}
  func handleInboundMessage(data map[string]interface{}) {
  	from := data["from"].(string)
  	text := data["text"].(string)

  	// Store the message
  	db.Messages.Create(from, text, data["channel"].(string))

  	// Send auto-reply if needed
  	if strings.Contains(strings.ToLower(text), "help") {
  		client.Messages.Send(context.TODO(), zavudev.MessageSendParams{
  			To:   zavudev.String(from),
  			Text: zavudev.String("Thanks for reaching out! A support agent will contact you shortly."),
  		})
  	}
  }
  ```

  ```php PHP theme={null}
  <?php
  function handleInboundMessage(array $data): void {
      $from = $data['from'];
      $text = $data['text'];
      $channel = $data['channel'];

      // Store the message
      Message::create([
          'from_number' => $from,
          'text' => $text,
          'channel' => $channel,
          'received_at' => now(),
      ]);

      // Send auto-reply if needed
      if (str_contains(strtolower($text), 'help')) {
          $client->messages->send([
              'to' => $from,
              'text' => 'Thanks for reaching out! A support agent will contact you shortly.',
          ]);
      }
  }
  ```
</CodeGroup>

***

## message.unsupported

Triggered when a customer sends a message type that is not supported by WhatsApp Cloud API. This includes polls, reactions, deleted messages, ephemeral messages, and third-party stickers.

```json theme={null}
{
  "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                  |
| `from`      | string | Customer's phone number (E.164 format)     |
| `to`        | string | Your Sender's phone number                 |
| `channel`   | string | Always `whatsapp` for unsupported messages |
| `timestamp` | number | Unix timestamp when message was received   |

<Note>
  Unsupported message types include:

  * **Polls** - Survey/voting messages
  * **Deleted messages** - Messages deleted by the sender
  * **Ephemeral messages** - Disappearing messages
  * **Third-party stickers** - Stickers not meeting WhatsApp specifications

  These limitations are from WhatsApp Cloud API, not Zavu.
</Note>

### Example Handler

<CodeGroup>
  ```typescript TypeScript theme={null}
  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."
    });
  }
  ```

  ```python Python theme={null}
  async def handle_unsupported_message(data):
      from_number = data["from"]
      message_id = data["messageId"]

      # Log unsupported message for analytics
      await db.unsupported_messages.create(
          message_id=message_id,
          from_number=from_number,
          received_at=datetime.now()
      )

      # Optionally notify the customer
      await zavu.messages.send(
          to=from_number,
          text="We received your message, but this message type is not supported. Please send a text message instead."
      )
  ```

  ```ruby Ruby theme={null}
  def handle_unsupported_message(data)
    from_number = data["from"]
    message_id = data["messageId"]

    # Log unsupported message for analytics
    UnsupportedMessage.create!(
      message_id: message_id,
      from_number: from_number,
      received_at: Time.now
    )

    # Optionally notify the customer
    client.messages.send(
      to: from_number,
      text: "We received your message, but this message type is not supported. Please send a text message instead."
    )
  end
  ```

  ```go Go theme={null}
  func handleUnsupportedMessage(data map[string]interface{}) {
  	from := data["from"].(string)
  	messageID := data["messageId"].(string)

  	// Log unsupported message for analytics
  	db.UnsupportedMessages.Create(messageID, from)

  	// Optionally notify the customer
  	client.Messages.Send(context.TODO(), zavudev.MessageSendParams{
  		To:   zavudev.String(from),
  		Text: zavudev.String("We received your message, but this message type is not supported. Please send a text message instead."),
  	})
  }
  ```

  ```php PHP theme={null}
  <?php
  function handleUnsupportedMessage(array $data): void {
      $from = $data['from'];
      $messageId = $data['messageId'];

      // Log unsupported message for analytics
      UnsupportedMessage::create([
          'message_id' => $messageId,
          'from_number' => $from,
          'received_at' => now(),
      ]);

      // Optionally notify the customer
      $client->messages->send([
          'to' => $from,
          'text' => 'We received your message, but this message type is not supported. Please send a text message instead.',
      ]);
  }
  ```
</CodeGroup>

***

## 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 theme={null}
{
  "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.
</Note>

### Example Handler

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 Python theme={null}
  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 Ruby theme={null}
  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 Go theme={null}
  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 theme={null}
  <?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();
      }
  }
  ```
</CodeGroup>

***

# 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 theme={null}
{
  "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 theme={null}
{
  "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 theme={null}
{
  "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`.
</Note>

***

## message.delivered

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

```json theme={null}
{
  "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.
</Note>

***

## message.failed

Triggered when message delivery fails.

```json theme={null}
{
  "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

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 Python theme={null}
  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 Ruby theme={null}
  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 Go theme={null}
  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 theme={null}
  <?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,
          ]);
      }
  }
  ```
</CodeGroup>

***

# 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 theme={null}
{
  "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

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 Python theme={null}
  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 Ruby theme={null}
  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 Go theme={null}
  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 theme={null}
  <?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',
          ]);
      }
  }
  ```
</CodeGroup>

***

# Partner Invitation Events

## invitation.status\_changed

Triggered when a partner invitation changes status. This is useful for tracking when your clients complete the WhatsApp onboarding process.

```json theme={null}
{
  "id": "evt_1705312200000_inv123",
  "type": "invitation.status_changed",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": {
    "invitationId": "inv_xyz789",
    "clientName": "Acme Corp",
    "clientEmail": "contact@acme.com",
    "previousStatus": "pending",
    "currentStatus": "completed",
    "senderId": "snd_newclient123",
    "wabaAccountId": "waba_abc456"
  }
}
```

### Data Fields

| Field            | Type           | Description                                                  |
| ---------------- | -------------- | ------------------------------------------------------------ |
| `invitationId`   | string         | Invitation identifier                                        |
| `clientName`     | string \| null | Client's business name                                       |
| `clientEmail`    | string \| null | Client's email address                                       |
| `previousStatus` | string         | Status before the change                                     |
| `currentStatus`  | string         | New status: `in_progress`, `completed`, or `cancelled`       |
| `senderId`       | string         | (Only on `completed`) The new sender created for this client |
| `wabaAccountId`  | string         | (Only on `completed`) The WhatsApp Business Account ID       |

### Status Flow

| Status        | Description                                                   |
| ------------- | ------------------------------------------------------------- |
| `pending`     | Invitation created, waiting for client                        |
| `in_progress` | Client started the WhatsApp signup flow                       |
| `completed`   | Client successfully connected their WhatsApp Business Account |
| `cancelled`   | Invitation was cancelled by the partner                       |

### Example Handler

<CodeGroup>
  ```typescript TypeScript theme={null}
  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`);
    }
  }
  ```

  ```python Python theme={null}
  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 Ruby theme={null}
  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 Go theme={null}
  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 theme={null}
  <?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";
      }
  }
  ```
</CodeGroup>

***

# 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 theme={null}
{
  "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

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 Python theme={null}
  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')
  ```
</CodeGroup>

***

## Best Practices

### Idempotency

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

```javascript theme={null}
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 theme={null}
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](/guides/receiving-messages/security) - Verify webhook signatures
* [Webhooks](/guides/receiving-messages/webhooks) - Configure your endpoints
