type in the form <domain>.<entity>.<action>. The envelope’s data field changes based on the type.
At a glance
Subscribe to what you actually process. Every subscription costs you a delivery your endpoint has to answer, and the high-volume ones —
whatsapp.message.delivered and whatsapp.message.read — fire once per recipient per message.WhatsApp messages
whatsapp.message.received
Inbound message from a user to the WhatsApp channel.
whatsapp.message.sent
Confirmation that the outbound message was accepted by Meta.
whatsapp.message.delivered
The outbound message reached the user’s device (gray double-check in WhatsApp).
whatsapp.message.read
The user opened the conversation and saw the message (blue double-check).
whatsapp.message.failed
Meta rejected the send. Error info is available in data.error.
WhatsApp templates
whatsapp.template.status_updated
Meta changed a template’s status (approval, rejection, pause, deletion).
WhatsApp channels
Both events fire for every channel type, not only WhatsApp — the names are historical. Readdata.type to tell them apart; the WhatsApp-specific fields are absent on the others.
whatsapp.channel.connected
A channel was created and is ready to send and receive. Useful for kicking off onboarding in your product.
whatsapp.channel.disconnected
The channel was disconnected — manually from the portal, through the API, or because Meta revoked access.
phone_number, phone_number_id, and business_account_id are absent here even for a WhatsApp channel. Correlate on channel_id.
WhatsApp Flows
whatsapp.flow.response
A recipient submitted a WhatsApp Flow. Fires alongside whatsapp.message.received for the same message — this one lifts the answers and the correlation token out of the raw interactive payload.
The shape of
response is defined by your own Flow JSON, not by Keebai — it is passed through as-is. Validate it on your side before writing it to a database.instagram.message.received
Inbound direct message on a connected Instagram account.
Conversations
conversation.created
A new conversation opened — the first message of a fresh exchange, after any previous one had closed. Fires for WhatsApp and Instagram.
chat_user_id is the contact’s id — the same one GET /v1/contacts returns as _id, and the same one CRM tickets reference as chat_user.
This fires alongside the
*.message.received event for the same message, not instead of it. If you subscribe to both, expect two deliveries.conversation.escalated_to_human
A ticket moved into a pipeline stage marked as human-attended, coming from one that was not. This is the “the bot gave up, a person is taking over” signal.
CRM
Fifteen events covering the four CRM entities. They fire on every write path — the portal, the public API, and the MCP server all go through the same service, so you get the event regardless of who made the change.Customers
crm.customer.created · crm.customer.updated · crm.customer.deleted
crm.customer.deleted the record is already gone, so only the id survives:
Tickets
crm.ticket.created · crm.ticket.updated · crm.ticket.deleted · crm.ticket.stage_changed · crm.ticket.closed
previous_stage_id, current_stage_id, is_human_stage, stage_type — appear only on crm.ticket.stage_changed.
Two things worth knowing about the ordering:
crm.ticket.closedcomes withcrm.ticket.updated, not instead of it. Closing a ticket is an update that also happens to close it, so you get both.closedonly fires on the transition from open to closed, never on a re-save of an already-closed ticket.- A stage move that escalates also emits
conversation.escalated_to_human. Same underlying change, two events, because they answer different questions.
Tasks
crm.task.created · crm.task.updated · crm.task.deleted · crm.task.completed
crm.task.completed accompanies crm.task.updated and fires only on the transition into completed.
Notes
crm.note.created · crm.note.updated · crm.note.deleted
Scheduling
Six events covering the appointment lifecycle. The payload carries the branch, service, professional, and customer inline, so a subscriber rarely needs a follow-up API call.scheduling.appointment.created
A new appointment was booked — from the portal, the public API, the assistant, or an external calendar sync.
scheduling.appointment.updated
Any change that is not a reschedule, a cancellation, or a confirmation — a professional swap, a price change, a note. Same appointment shape.
When the change came from a status transition, data.previous_status carries the old value.
scheduling.appointment.confirmed
The appointment moved to confirmed. Fires alongside scheduling.appointment.updated, with data.previous_status.
scheduling.appointment.rescheduled
The appointment moved to a different time or professional. The previous slot is included so you can update an external calendar without keeping your own copy.
scheduling.appointment.cancelled
The appointment was cancelled. data.cancellation_reason is present when the caller supplied one.
scheduling.appointment.no_show
The appointment was marked as a no-show. Fires alongside scheduling.appointment.updated, with data.previous_status.
Ecommerce
Three events on the sale lifecycle. The payload carries the customer, the line items, and the full amount breakdown, so a subscriber can post to an accounting system without a follow-up call. See the ecommerce overview for how the records relate.ecommerce.sale.created
A sale was closed — from the portal, the point of sale, or the assistant.
ecommerce.sale.paid
The sale reached payment_status: "paid". This is the one to subscribe to for accounting and fulfilment.
It fires in two situations, and both deliver the same payload:
- Alongside
ecommerce.sale.created, when the sale was already fully paid at the moment it was rung up. Expect two deliveries for one sale. - On its own, when a later payment settles a sale that was
pendingorpartially_paid.
A sale that goes straight from
pending to paid in several instalments only fires this once — on the payment that completes it. Partial payments do not emit an event.ecommerce.sale.cancelled
The sale was cancelled. Stock that had been discounted is returned and commissions are zeroed before the event fires, so the payload already reflects the reversal.
Synthetic events (_test: true)
When you run keebai webhooks test <id> --event ... or POST /v1/webhooks/:id/test, we dispatch a synthetic event with the same shape as the real one, but with data._test: true. Useful for validating your pipeline in staging without real traffic.