Skip to main content
Every event is identified by a 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.
Only the message and conversation events carry project_id and channel_id. CRM, scheduling, channel, and template events are emitted from services that do not resolve a project, so those envelope fields are absent. Route on company_id, and take any further scoping from data.

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. Read data.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.
The WhatsApp credentials are cleared as part of the disconnect, so 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

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.
It fires on the transition, once — not on every message while a human is attending. Moving back to a bot stage and escalating again fires it a second time.

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.
These events are not filtered by project. A subscription receives CRM events from every project in your company, matching the behaviour of the CRM read endpoints.

Customers

crm.customer.created · crm.customer.updated · crm.customer.deleted
On 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
The four extra fields — 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.closed comes with crm.ticket.updated, not instead of it. Closing a ticket is an update that also happens to close it, so you get both. closed only 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
Like tickets, 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 pending or partially_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.
Cancelling does not refund. payment_status keeps whatever it was, so a cancelled sale can still read paid. If money needs to go back to the customer, that happens in your payment provider, not here.

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.
In your handler, dedup by X-Keebai-Event-Id or body.id. Retries on timeout / 5xx arrive with the same id but a different X-Keebai-Delivery-Id.