Zavu sends webhook events for message lifecycle stages. There are several categories:
Category Events Purpose Inbound conversation.new, message.inbound, message.unsupported, message.reactionReceive messages, reactions, and new contact notifications Outbound message.queued, message.sent, message.delivered, message.failedTrack your outbound message delivery Templates template.status_changedTrack WhatsApp template approval status Broadcasts broadcast.status_changedTrack broadcast delivery lifecycle Invitations invitation.status_changedTrack partner invitation status changes
Event Structure
All events follow this structure:
{
"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.
{
"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 phoneNumberstring New contact’s phone number (E.164 format) channelstring Channel used: sms or whatsapp firstMessageIdstring ID of the first message firstMessageTextstring Content of the first message profileNamestring | null Contact’s WhatsApp profile name. null for SMS.
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 via SMS or WhatsApp. This includes text messages, images, videos, audio, documents, and stickers.
Text Message Example
{
"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
{
"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"
}
}
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
{
"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"
}
}
{
"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:
{
"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"
}
}
The interactiveReply.id is the ID you specified when sending the button or list message. Use this to identify which option the user selected.
Data Fields
Field Type Description messageIdstring Unique message identifier fromstring Customer’s phone number (E.164 format) tostring Your Sender’s phone number channelstring sms or whatsappmessageTypestring Message type: text, image, video, audio, document, sticker, location, contact textstring Message text or media caption (also button/list reply title for interactive messages) contentobject | null Additional content (for non-text messages) content.mediaIdstring Media ID from WhatsApp (media messages) content.mimeTypestring MIME type of the media content.filenamestring Filename (for documents) content.latitudenumber Latitude coordinate (location messages) content.longitudenumber Longitude coordinate (location messages) content.namestring Location name (location messages) content.addressstring Location address (location messages) content.contactsarray Contact cards (contact messages) content.interactiveReplyobject Interactive reply details (when user clicks a button or list item) content.interactiveReply.typestring button_reply or list_replycontent.interactiveReply.idstring ID of the button/list item clicked content.interactiveReply.titlestring Title of the button/list item clicked profileNamestring | null Sender’s WhatsApp profile name. null for SMS.
Example Handler
TypeScript
Python
Ruby
Go
PHP
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 is not supported by WhatsApp Cloud API. This includes polls, reactions, deleted messages, ephemeral messages, and third-party stickers.
{
"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 messageIdstring Unique message identifier fromstring Customer’s phone number (E.164 format) tostring Your Sender’s phone number channelstring Always whatsapp for unsupported messages timestampnumber Unix timestamp when message was received
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.
Example Handler
TypeScript
Python
Ruby
Go
PHP
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.
{
"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 messageIdstring ID of the message that was reacted to providerMessageIdstring WhatsApp message ID reaction.emojistring The emoji used for the reaction reaction.fromstring Phone number of the person who reacted reaction.timestampnumber Unix timestamp of the reaction reaction.actionstring added when reaction is added, removed when reaction is removed
When a user removes a reaction, action will be removed and emoji will be an empty string.
Example Handler
TypeScript
Python
Ruby
Go
PHP
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 ,
});
}
}
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.
{
"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 messageIdstring Your message identifier tostring Recipient’s phone number channelstring sms or whatsappstatusstring 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.
{
"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 messageIdstring Your message identifier tostring Recipient’s phone number channelstring sms or whatsappstatusstring Always sent for this event
message.delivered
Triggered when your message is confirmed delivered to the recipient’s device.
{
"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 messageIdstring Your message identifier tostring Recipient’s phone number channelstring sms or whatsappstatusstring Always delivered for this event
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.
{
"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 messageIdstring Your message identifier tostring Recipient’s phone number channelstring sms or whatsappstatusstring Always failed for this event errorCodestring Error code from carrier/provider errorMessagestring Human-readable error description
Common Error Codes
Code Description Suggested Action 30003Unreachable destination Number may be invalid or disconnected 30005Unknown destination Verify the phone number format 30006Landline not supported SMS cannot be sent to landlines 30007Carrier rejected Message blocked by carrier 21610Unsubscribed recipient User opted out of messages
Example Handler
TypeScript
Python
Ruby
Go
PHP
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
});
}
}
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.
{
"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 templateIdstring Template identifier templateNamestring Template name languagestring Template language code previousStatusstring Status before the change currentStatusstring New status: approved, rejected, pending, disabled rejectionReasonstring | null Reason for rejection (only present when currentStatus is rejected)
Status Values
Status Description draftTemplate created locally, not yet submitted pendingTemplate submitted to Meta, awaiting review approvedTemplate approved and ready to use rejectedTemplate rejected by Meta disabledTemplate was approved but later disabled by Meta
Example Handler
TypeScript
Python
Ruby
Go
PHP
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"
});
}
}
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.
{
"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 invitationIdstring Invitation identifier clientNamestring | null Client’s business name clientEmailstring | null Client’s email address previousStatusstring Status before the change currentStatusstring New status: in_progress, completed, or cancelled senderIdstring (Only on completed) The new sender created for this client wabaAccountIdstring (Only on completed) The WhatsApp Business Account ID
Status Flow
Status Description pendingInvitation created, waiting for client in_progressClient started the WhatsApp signup flow completedClient successfully connected their WhatsApp Business Account cancelledInvitation was cancelled by the partner
Example Handler
TypeScript
Python
Ruby
Go
PHP
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` );
}
}
Broadcast Events
broadcast.status_changed
Triggered when a broadcast changes status. This is a project-level event — configure it in your project webhook settings.
{
"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 broadcastIdstring Broadcast identifier namestring Broadcast campaign name statusstring New status after the change previousStatusstring Status before the change channelstring Broadcast channel (sms, whatsapp, email, etc.) totalContactsnumber Total contacts in the broadcast
Status Flow
draft → pending_review → approved → sending → completed
↓
rejected → escalated → rejected_final
↑
(edit + retry)
Status Description draftInitial state, adding contacts pending_reviewAI content review in progress approvedReview passed, ready to send rejectedContent rejected — edit and retry escalatedSent to human review rejected_finalRejected by human review (cannot appeal) sendingMessages being delivered completedAll messages processed cancelledBroadcast stopped
Example Handler
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 ;
}
}
Best Practices
Idempotency
Webhook deliveries may be retried, so your handler should be idempotent:
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:
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