Webhook event reference
Webhooks deliver real-time events to your system’s URL. This page documents every event, its payload, and how to correlate events with your API calls. To create and manage webhooks, see Configure webhooks.Which events your number sends
The event vocabulary depends on how the number was connected. A number paired by QR code speaks the canonical Pilot Status vocabulary documented on this page; a Meta Cloud API number forwards Meta’s own native envelope instead. They are different formats with different names, and no subscription setting converts one into the other.
Three things are the same on every vocabulary:
- Number lifecycle —
number.created,number.connected,number.disconnected,number.removed,number.restoredare subscribable on every number, whatever its provider. (number.disconnectedonly ever fires on native web numbers — seenumber.disconnected.) - Normalized call events —
call.ringing,call.connected,call.ended,call.missedon any calling-capable number. eventsis never optional. A webhook stored with an emptyeventsarray delivers nothing, on every vocabulary, and says nothing about it — it is a silent201. Subscribe to the names you want, or to the"*"wildcard.
Not sure which kind your number is?
GET /v1/numbers returns provider per number: PILOT_STATUS for native web, META for Cloud API. See Official vs unofficial.Payload format
Every event arrives as JSON:data fields are camelCase. Phone numbers are always E.164 with + (no device suffix, no @s.whatsapp.net / @lid). Timestamps are always ISO 8601 in the single createdAt field (when the message happened, per the provider — not our ingestion time).
New event names keep appearing — ignore the ones you do not know. A
"*" subscription receives a new event as soon as it exists (number.restored is one). Answer an event your code does not handle with a 2xx and move on. Starting with @pilot-status/sdk 0.12.0 (Node) and pilot-status 1.10.0 (Python), parseCustomerWebhook and parse_customer_webhook no longer throw on an event name they do not know: they return { type: "unknown", event, data, raw } (in Python, a dict with the same keys), so a new event never breaks your integration. A payload that is not an object with a string event, or a known event with invalid fields, still throws. The n8n trigger already passes any event through when All Events is selected. See SDKs & n8n.Old messages are not replayed. When a number connects, WhatsApp hands the provider the device’s history. Those messages are imported into the chat, but they are not delivered to your webhook — otherwise every reconnect would re-deliver up to 30 days of conversation and nothing in the payload would let you tell it apart from traffic that just arrived.Opt in per number with
settings.webhookHistoricalMessages on PATCH /v1/numbers/{id}. To read history instead, use GET /v1/messages/history.Available events
Outbound (delivery/status):message.sent, message.delivered, message.read, message.failed
Inbound (received messages):
message.reply— reply that quotes another messagemessage.received— message received on the connected number
message.group— message received in a groupmessage.newsletter— message received in a channel (@newsletter)message.stories— WhatsApp Status (story) posted by a contact
number.created— number created in Pilot Statusnumber.connected— connected to WhatsApp (OPEN state)number.disconnected— connection drop confirmed by the number-health transition, on native web (unofficial) numbers (one event per confirmed drop, not one per connectivity blip).number.removed— number removednumber.restored— a deleted number was restored within its 30-day window; it comes back disconnected (seenumber.restored)number.recovered— number recovered from a degraded/blocked state
number.health_blocked, number.health_degraded, number.health_shadowban — number.recovered, number.inbound_stalled and number.inbound_recovered are in the event picker and subscribable by name on every provider since 2026-09-15; they also reach webhooks subscribed with the "*" wildcard. Like every number.* event, they go only to webhooks subscribed to that number.
Voice calls (WhatsApp Business Calling, see Voice Calls):
The normalized call events — call.ringing, call.connected, call.ended, call.missed — are available on any calling-capable number (native web (unofficial) — and Meta Cloud API). call.permission_updated and the native calls envelope are Meta Cloud API only.
call.ringing— call ringing (inbound UIC or outbound BIC transition)call.connected— call answered/connectedcall.ended— call finished (status:COMPLETED|FAILED|REJECTED;durationseconds when answered)call.missed— inbound call ended without being answeredcall.permission_updated— (Meta Cloud API only) reply to a call-permission request (status:NO_PERMISSION|TEMPORARY|PERMANENT)calls— (Meta Cloud API only) Meta’s nativecallsenvelope (rawentry[].changes[].value, carrying SDPs) for custom WebRTC signaling
flow.response_received— a customer submitted a Flow, carrying the answers. Seeflow.*payload.
Identifiers and correlation
Every message event carries two distinct IDs:messageId— WhatsApp/provider message ID (e.g.key.id). Exception: on amessage.failedsent by the worker or for the plan limit it carries the internal id instead, and the reconciler’s flat body has nomessageId— seemessage.failedbelow.id— Pilot Status internal message ID. Same value as theidin theHTTP 202ofPOST /v1/messages/send.numberId— the public ID of the number that handled the event. On everynumber.*event it is now always the WhatsApp number ID (see the breaking change on thenumber.*family).correlationId— present when the event correlates to a prior send (same value as the202correlationId).quotedMessageId— onmessage.reply, themessageIdof the quoted original message (equals themessageIdof the originalmessage.sent).
Correlation with POST /v1/messages/send
After an accepted send, the API returns HTTP 202 with id and correlationId:
On
message.reply: quotedMessageId = the messageId of the original message.sent; the reply’s own messageId is the new inbound message. Use quotedMessageId (and correlationId when present) to match the reply to your prior send.
message.* payload
Common fields
⚠️
message.failed is the exception: two of its three body shapes do not follow this table — see message.failed below.
from/to direction semantics:
- Outbound (
sent/delivered/read/failed):to= destination number (E.164);from= own number (present when cheaply resolvable, otherwise omitted);fromMe=true. - Inbound (
received/reply):from= contact/sender (E.164);to= own number (E.164);fromMe=false. - Group / channel (
group/newsletter):from= participant (E.164);to= own number; plusgroupId/groupNameornewsletterId/newsletterName. - Status (
stories):from= the contact who posted the story (E.164). There is noto: a Status is broadcast to the poster’s contact list, not addressed to your number.
message.sent
message.delivered
message.read
message.read only fires when the recipient has WhatsApp read receipts enabled.message.failed
message.failed is sent for unofficial (web) numbers only — an official number reports a failure in Meta’s native statuses[] instead. Its body is not uniform: it depends on where the failure was detected, and it is left as it is on purpose, because existing subscribers already parse it. Three shapes reach you. None of their codes is the API’s errorCode: to classify a failure, read errorCode on GET /v1/messages/{messageId} — Webhook error codes vs API error codes maps one to the other.
1. Status shape — error and errorCode. Sent when the provider reports the message as failed (errorCode: "SEND_FAILED"), and when the plan’s message limit stops a send (errorCode: "PLAN_LIMIT_REACHED"). It follows the common fields above; messageId is the provider’s id.
data.event, internalMessageId or whatsappNumberId, and its messageId is the internal id, because nothing reached the provider.
2. Worker shape — errorMessage, no code. Sent by the send worker for its own failures: the destination is not on WhatsApp, a shadowban, a connection that is missing, not connected or gone, a payment-order card that failed after the text went out, the last failed attempt, or an unexpected error. data carries messageId, internalMessageId, correlationId, environment, destinationNumber, content, status, failedAt and the reason in errorMessage — no error, errorCode, id, from, to, type, fromMe or createdAt. Here messageId is the internal id, the same value as internalMessageId. The two connection failures (no connected connection, none assigned) omit internalMessageId and correlationId; the unexpected-error path adds sourceNumber.
code. The message reconciler, which fails a message whose delivery deadline passed, and the send worker’s check of that deadline before a retry send a body with no event and no data: the fields sit at the top level. No header names the event either, so recognise this shape by status: "FAILED" with a top-level internalMessageId. The reconciler sends code: NUMBER_BLOCKED when the number was blocked, DELIVER_UNTIL_EXPIRED otherwise. The retry check sends no code and adds messageId (the internal id).
idempotencyKey described in Deduplicate retries.
message.received
message.received / message.reply) that carry media, mediaLink is a durable, publicly-fetchable URL to the media re-hosted on Pilot Status storage — for all providers (Evolution GO, Evolution v2, and Meta Cloud API), including WhatsApp end-to-end-encrypted media, which is decrypted server-side before re-hosting. The media* fields are omitted on text-only messages.
message.reply
The contact’s new text is incontent; the quoted original message text is in contentReplied. Use quotedMessageId to match the messageId from your original outbound message.sent.
message.group
message.newsletter
message.stories
WhatsApp Status (stories) posted by a contact the connected phone can see.A Status is not persisted. It does not create a conversation or a message in
your chat history, and it does not appear in
GET /v1/messages. WhatsApp expires
stories after 24h; the mediaLink we re-host is durable, the story is not.number.* payload
Fields for number.created / number.connected / number.disconnected / number.removed / number.restored / number.recovered (and the number.health_blocked / number.health_degraded / number.health_shadowban events):
number.created
number.connected
number.disconnected
Emitted from the number-health transition, which owns the anti-flap window and the one-alert-per-transition dedup: you get one event per confirmed drop, not one per socket blip.- Native web (unofficial) numbers: this event fires on a confirmed connection drop. Previously an unofficial number that fell off produced no subscribable event at all — only the
"*"-only health events. - Meta Cloud API numbers: they never emit
number.disconnected. A Meta quality/status transition emits only its number-health event (number.health_blocked/number.health_degraded/number.health_shadowban), which reaches webhooks subscribed to it by name or to"*".
error and errorCode are included when the health transition carries a reason.
number.removed
number.restored
The pair ofnumber.removed: a deleted number was brought back within its 30-day window.
- Fires on every restore path: the dashboard,
POST /v1/numbers/{id}/restore, and reconnecting the same phone on Meta (Embedded Signup or Cloud API credentials) while its deleted number is still restorable. That reconnect brings the old number back, sonumber.restoredis followed bynumber.created/number.connected. - It restores the record, not the connection: the number comes back disconnected and has to be reconnected before it can send.
- Same payload and scope as
number.removed: the samenumberId(the idGET /v1/numbersreturns), delivered only to webhooks subscribed to that number, including"*"subscriptions. Subscribable by name on every provider. - If your integration stops accepting a number when it receives
number.removed, accept it again onnumber.restored. Otherwise you keep rejecting its events after the restore.
call.* payload
Unlike message.* / number.*, the normalized call.* events are flat (no data wrapper):
See the full calling flow.
flow.* payload
flow.response_received fires when a customer submits a Flow. It uses the canonical { event, data } envelope even on a Meta number, and it is only available on Meta Cloud API numbers, because Flows only exist there.
Plain Flow — no endpoint, answers in response
data_exchange Flow — answers in submitted, attachment still transferring
submitted is the list of data_exchange exchanges, oldest first. Each entry is screen (a string, or null), at (ISO 8601), data (the parsed object, or null) and omitted. The field is null — never [] — both when there is nothing to show and when the content was redacted, because [] would read as “there were answers and we hid them”.Only data_exchange exchanges are captured. An INIT hands back the state we seeded when the Flow was sent, and a BACK is navigation; neither is something the person answered, so neither is kept. The list is therefore legitimately shorter than your endpoint’s traces for the same session — an INIT that is missing from it is not a lost exchange.It captures the request Meta sends us, never what your endpoint replied. The extension_message_response your API returns to finish the Flow still appears nowhere in Pilot Status, and this field does not change that — see Connect your API to a Flow.Append-only, with no de-duplication. Someone who walks back a screen and submits it again produces a second, genuine entry instead of overwriting the first; at is what separates two runs under the same token.The exchanges are kept for 30 days + 1, and the extra day is not decoration. An exchange is captured while the person is still filling the form — minutes before the submission that creates the response row. On an identical 30 days the exchanges would expire first, and a response still inside its own window would publish an empty list for its last minutes.
response or responseRaw, never both. Meta sends the nfm_reply body as a JSON string. When it parses you get response; when it does not you get responseRaw with the bytes as they arrived. The submission is never dropped for being unparseable, so check which one you got — and remember that on a data_exchange Flow what is in there is the flow_token, not the answers.No answers means no event. A submission that carries nothing at all does not fire — an empty body would be indistinguishable, on your side, from a form submitted blank.The event also fires only for a submission that was stored. A Meta redelivery of one already kept is silent, which is the deduplication you want; so is a second, distinct submission that collided on the same
flow_token literal, which is the one case where silence costs you an answer. Tokens minted by Pilot Status never collide; a Flow built in Meta’s Flow Manager with a fixed flow_token can.Meta Cloud API events (native envelope)
A number connected through the Meta Cloud API — Embedded Signup or pasted credentials — does not send the canonical events above. It forwards Meta’s own webhook envelope, verbatim, keyed by the field name Meta puts inentry[].changes[].field.
Native envelope (as delivered)
entry[].changes[].value is Meta’s payload for that field, unmodified — the same shape Meta’s own reference documents. Nothing is added to it and nothing is removed, so a receiver already written against Meta’s webhooks keeps working.
One change per delivery. Meta may batch several changes into one entry; each one is subscription-matched and delivered on its own, so entry and entry[0].changes always have exactly one element. Keep the loops your Meta parser already has — they will simply always run once — and never assume two changes in one request are related.
Subscribable fields
These are the field names you can put inevents on POST /v1/webhooks for a Meta number. They are also the names that arrive as the delivered event.
Plus the families that are not Meta’s envelope:
- Flow responses —
flow.response_received, Meta numbers only, in the canonical{ event, data }shape. Seeflow.*payload. - Normalized call events —
call.ringing,call.connected,call.ended,call.missed, andcall.permission_updated(Meta only). These are flat Pilot Status events, not Meta’s envelope — seecall.*payload. - Number lifecycle —
number.created,number.connected,number.removed,number.restored.number.disconnectedis subscribable but never fires on a Meta number: a Meta quality/status transition emits a number-health event instead, and those reach"*"subscriptions only.
Nine of Meta’s fields are never delivered, not even to a
"*" subscription: account_alerts, automatic_events, history, messaging_handovers, partner_solutions, security, smb_app_state_sync, standby and tracking_events. They are internal (bulk history backfill, coexistence state sync) or admin/security traffic, and they are not offered in the event picker either. Everything else Meta sends for a subscribed field is forwarded.Number health — subscribable since 2026-09-15
A Meta number never emitsnumber.disconnected. What it emits instead is the number-health family, and these names are now in the event picker — subscribe to them by name, like any other event:
They were
"*"-only until 2026-09-15. The dispatcher always matched them, but they were absent from the picker — so a webhook subscribed to exactly what it wanted received no health signal at all, and nothing said so. If you took "*" purely to get these, you can now narrow the subscription.{ event, data } shape (data: numberId, phone, displayName, createdAt, and error / errorCode when present) — not Meta’s envelope. So a subscription that mixes these with Meta fields delivers two different body shapes to the same URL: Meta’s { object, entry } for the subscribed fields, and { event, data } for these plus call.*, flow.response_received and number.*. Branch on the presence of entry before parsing.
If you want the conversation traffic and the health traffic on different endpoints, create two webhooks on the number: one with the Meta fields you need, one with
"*". See how many webhooks per number.Echoes: what the business sent from somewhere else
message_echoes and smb_message_echoes both carry a copy of a message the business sent — the first for sends made through any other tool on the same WABA, the second for sends made from the WhatsApp Business app on the phone (coexistence). On a coexistence number, smb_message_echoes is how an agent’s reply typed on the device reaches your system at all.
The echoed message has the same per-type shape as an inbound message (text / image / video / audio / document / sticker / location / contacts / reaction), plus the recipient:
statuses[] — this is where the billed category is
On the messages field, delivery statuses ride value.statuses, and each one carries Meta’s own billing verdict. This is the authoritative answer to “what did Meta charge for this message” — it does not have to be inferred from the template.
The combinations actually observed in production (surveyed over 30 days of deliveries):
conversation.id and conversation.expiration_timestamp are also the honest way to answer “did a second ad click open a new window or extend the old one?” — compare the ids rather than assuming. pricing.type then says whether Meta charged for it.Matching a status to your send — biz_opaque_callback_data
A message sent from an official number with POST /v1/messages/send goes to Meta with the id from the HTTP 202 in biz_opaque_callback_data, and Meta returns it inside every statuses[] item for that message. That links your request to Meta’s status directly: no lookup, and no waiting for the wamid.
- It is the
id, never thecorrelationId. ThecorrelationIdis your own value and does not have to be unique. Theidis unique. - Observed on
sent,deliveredandread, for both templates and free-form messages. - Only on messages sent through Pilot Status from 2026-09-17, 17:23 UTC. The key is absent, not empty, on statuses for messages sent before that date, sent straight through the Graph API, or sent from the WhatsApp Business app (see echoes).
- If the field is missing, use the wamid.
GET /v1/messages/{messageId}accepts the wamid (statuses[].id) and returns the message’sidandcorrelationId.
CTWA referral — where an ad click lands
There is no separatereferral field to subscribe to. The block arrives inside the messages envelope, at entry[0].changes[0].value.messages[0].referral, verbatim.
source_idis the ad id (source_type: "ad") or the post id (source_type: "post"). Treat it as an opaque grouping key: resolving it to a campaign/ad name through the Graph needsads_readon the advertiser’s ad account, which a WhatsApp system-user token does not carry (it comes backGraphMethodExceptioncode 100 / subcode 33).- There is no UTM in a Meta referral. If you need campaign names, use
GET /v1/referrals— it returns the row immediately, with the names resolved asynchronously (namesResolvedAt: nullmeans “not resolved yet, ask again”).
Migration-layer events (Evolution GO and v2)
A number you brought over through a compatibility layer keeps that provider’s own event vocabulary — the point of the layer is that your existing handlers do not change:- Evolution GO — native PascalCase (
Message,SendMessage,Receipt,Presence, …), with the UPPER_SNAKE aliases also accepted. Full list on the Evolution GO layer page. - Evolution v2 — native dotted names (
messages.upsert,messages.update,connection.update, …). Full list on the Evolution V2 layer page.
call.* and number.* families described above.
Group and channel events on these two need a paid plan:
groups.upsert, groups.update, group-participants.update (v2) and GroupInfo, JoinedGroup, NewsletterJoin, NewsletterLeave (GO) are hidden from the event picker on a free account. The canonical message.group and message.newsletter are not restricted.Important notes
message.newsletter:newsletterNameis the channel display name when available; otherwise it may be omitted. The channel identifier isnewsletterId(full...@newsletterJID).participantNameis the message author in the channel.- Media: on inbound media events,
mediaLinkis a durable, publicly-fetchable URL to the re-hosted media — for all providers (Evolution GO, Evolution v2, Meta Cloud API), including WhatsApp end-to-end-encrypted media decrypted server-side.mediaTypeis the category (image/video/audio/document/sticker),mediaCaptionthe accompanying text,mediaFilenamethe original filename (documents). All four are omitted on text-only messages and when a number’s PII redaction blanks content. number.*events are not correlated toPOST /v1/messages/send; use them for provisioning/monitoring.data.numberIdis always the WhatsApp number ID on all of them — see the breaking change.number.disconnectedis emitted from the number-health transition (one event per confirmed drop, not one per connection flap), and only for native web (unofficial) numbers; Meta Cloud API numbers signal trouble through the number-health events instead.- Delivery of sensitive fields can depend on retention configuration. With retention off, conditional fields such as
contentmay be empty; IDs and timestamps still exist. - The
message.readevent (and Read status in the API/Logs) only occurs when the recipient has WhatsApp read receipts enabled.