Skip to main content

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.
Changed on 2026-09-17 — official numbers. Until that date message.sent and message.failed did reach webhooks on a Meta Cloud API number that had subscribed to the "*" wildcard, even though the native envelope was already the documented vocabulary there. They no longer do: no canonical message.* event is dispatched on an official number, whatever the cause.Read value.statuses[] from the native envelope instead, or poll GET /v1/messages?status=FAILED — the message row reaches FAILED on both connection types, including for a failure Meta itself reported. Unofficial numbers are unaffected.
Three things are the same on every vocabulary:
  • Number lifecycle — number.created, number.connected, number.disconnected, number.removed, number.restored are subscribable on every number, whatever its provider. (number.disconnected only ever fires on native web numbers — see number.disconnected.)
  • Normalized call events — call.ringing, call.connected, call.ended, call.missed on any calling-capable number.
  • events is never optional. A webhook stored with an empty events array delivers nothing, on every vocabulary, and says nothing about it — it is a silent 201. 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:
Schema v3 (camelCase). All 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 message
  • message.received — message received on the connected number
message.reply is exclusive — it does NOT also fire message.received.When a contact replies quoting an earlier message (swipe-to-reply in WhatsApp), the event is message.reply and only that one. If your webhook subscribes to message.received but not to message.reply, those messages never reach you — and nothing reports an error, so it looks like the customer never wrote.Subscribe to both, or use the * wildcard, if you want every inbound message.
  • message.group — message received in a group
  • message.newsletter — message received in a channel (@newsletter)
  • message.stories — WhatsApp Status (story) posted by a contact
Number lifecycle:
  • number.created — number created in Pilot Status
  • number.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 removed
  • number.restored — a deleted number was restored within its 30-day window; it comes back disconnected (see number.restored)
  • number.recovered — number recovered from a degraded/blocked state
Health events: the health transition events — 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/connected
  • call.ended — call finished (status: COMPLETED | FAILED | REJECTED; duration seconds when answered)
  • call.missed — inbound call ended without being answered
  • call.permission_updated — (Meta Cloud API only) reply to a call-permission request (status: NO_PERMISSION | TEMPORARY | PERMANENT)
  • calls — (Meta Cloud API only) Meta’s native calls envelope (raw entry[].changes[].value, carrying SDPs) for custom WebRTC signaling
WhatsApp Flows (Meta Cloud API numbers only, see Flows):
  • flow.response_received — a customer submitted a Flow, carrying the answers. See flow.* payload.

Identifiers and correlation

Every message event carries two distinct IDs:
  • messageId — WhatsApp/provider message ID (e.g. key.id). Exception: on a message.failed sent by the worker or for the plan limit it carries the internal id instead, and the reconciler’s flat body has no messageId — see message.failed below.
  • id — Pilot Status internal message ID. Same value as the id in the HTTP 202 of POST /v1/messages/send.
  • numberId — the public ID of the number that handled the event. On every number.* event it is now always the WhatsApp number ID (see the breaking change on the number.* family).
  • correlationId — present when the event correlates to a prior send (same value as the 202 correlationId).
  • quotedMessageId — on message.reply, the messageId of the quoted original message (equals the messageId of the original message.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; plus groupId/groupName or newsletterId/newsletterName.
  • Status (stories): from = the contact who posted the story (E.164). There is no to: 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.
The plan-limit variant has no 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.
3. Flat shape — top-level 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).
All three shapes also get the top-level idempotencyKey described in Deduplicate retries.

message.received

Media example. On inbound events (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 in content; 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.
Only delivered while someone is subscribed. Status traffic is switched off at the provider by default (ignoreStatus); subscribing a webhook to message.stories — or to the "*" wildcard — is what turns it on for that number. Remove the last subscription and the provider stops sending Status again.
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):
Breaking change — data.numberId is always the number ID. On every number.* event, data.numberId is now the WhatsApp number ID. Previously number.created, number.connected and number.removed carried the internal instance ID, while the health events already carried the number ID: two opaque IDs in the same field, so cross-referencing the two families silently never matched. If your consumer matched lifecycle events by instance ID, it will stop matching — key it on the number ID instead. It is the same id GET /v1/numbers returns.

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 of number.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, so number.restored is followed by number.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 same numberId (the id GET /v1/numbers returns), 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 on number.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
In a data_exchange Flow, data.response is the empty place. It is Meta’s nfm_reply, and when the Flow is served by an endpoint that reply carries the token and nothing else — measured three times in production as {"flow_token":"2kziUbM0UDzp4Dq2WKrI26PGxRSSodn7WNHa8FnisJM"}, while the same submission had already handed the customer’s own endpoint {"observacao":"Aobaaa","foto":[…]}. What the person typed is in data.submitted; a handler that goes to response for it reports an empty form on every Flow you connect to an API.In a plain Flow — one with no endpoint — free-text components do arrive in response. Nothing intercepts those screens, so there is nothing to capture on the way and submitted is null. Which field holds the answers is decided by the Flow you sent, not by the payload: read submitted on a data_exchange Flow, response on a plain one. See Connect your API to a Flow.
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.
Nothing is ever silently truncated — an entry that could not keep its content says so, in place. An entry either has data with omitted: null, or data: null and a reason in omitted. There is no third shape, and omitted is exactly one of three values.The ceiling is counted in bytes, not characters — an answer written with accents, or carrying emoji, reaches it earlier than its length on screen suggests.
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.
flowMedia carries a URL from the first delivery, including while the file is still on its way to us. Each entry is field (whatever the Flow’s author named the component — not a reserved word, not a type), fileName, mimeType, status and url. The bytes sit on Meta’s CDN and are re-hosted by a queue: the event is emitted as soon as the submission is stored, and that transfer runs after it, so a submission is ordinarily announced while the file is still in flight. That ordering is why the link exists, not a caveat to work around: url is there at PENDING too.Fetch it and it waits for the file. The route holds the request while the re-host finishes, then serves the file; every later fetch is a redirect. A wait that runs out answers 404 with a Retry-After header, and the same link keeps working after it. The value does not change once the bytes land, so it is safe to store on arrival — status (PENDING / STORED / FAILED) is still what tells you whether they have. Treat the link as opaque: fetch it, never construct one. It is null in three cases, and “still transferring” is none of them — no signing secret on the deployment, which falls back to the direct object URL once STORED; no media_id from Meta to sign an identity over; or a RELAY_ONLY number, where nothing was stored at all.null means four different things, and only one of them is “nothing was attached”. It is also what you get when Meta echoed no flow_token with the submission; when the attachment lookup itself failed, which is best-effort and costs a field rather than the event; and before the re-host job starts, because the row that would carry PENDING is only written once the transfer actually starts, by the backend that performs it, and not at the moment the file is queued — so until then there is nothing to list. null is therefore not proof that no file was sent. A Flow that collects files is read back at GET /v1/flows/{id}/responses, never concluded from this field.The field is never []. And the event carries no cdn_url and no encryption_metadata: Meta derives one AES key per file, and forwarding it would put the key that opens the customer’s document into your webhook, and into your logs.
The message.* side of a submission carries no answers, and that is deliberate. Meta delivers a submitted Flow as an interactive reply of type nfm_reply, whose human-readable body is a bare label — Sent / Enviado. That label is what lands in content, in chat_messages.text and in GET /v1/messages; the answers never go there, because content is promised to be message text and a JSON blob in it would break every consumer that renders or matches on it.Subscribe to flow.response_received — a webhook whose events list contains only messages receives the label and nothing else, with no error anywhere. See Receive Flow responses.
The number field is whatsappNumberId here, and numberId on every other event. It holds the same value — the id GET /v1/numbers returns — and only the name differs, but it differs on the wire: a handler that reads data.numberId on this event gets undefined, silently, and routes the answers to nowhere. The name is copied from the emitter, not chosen at this boundary, which is why it was not simply renamed to match.
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.
A chosen option arrives as its id, not as the label the person read. RadioButtonsGroup, Dropdown and ChipsSelector declare their options in the Flow JSON as a data-source of id + title pairs. Against [{"id":"5","title":"Otimo"}, {"id":"3","title":"Razoavel"}, {"id":"1","title":"Ruim"}], someone who taps Otimo submits {"nota":"5","comentario":"AOBA","flow_token":"..."}. WhatsApp’s “Your response” screen shows Otimo because the phone still holds the Flow JSON; your endpoint gets 5, and nothing in the payload says what 5 meant.Resolving the label afterwards can hand you the wrong label: the id is stable, the title is not, so a Flow republished with {"id":"5","title":"Excelente"} makes a later lookup name a word the customer never saw. Capture the label at the moment the submission arrives, and keep it beside the id. See Receive Flow responses.
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.
On a number configured to keep no customer content, the delivery is complete and the stored copy is not. Your endpoint receives the full body; in the payload kept in the webhook log response, responseRaw, submitted and flowMedia become null, and from becomes the placeholder string conteúdo indisponível — a string, not a null, so do not test for one. A log entry that looks empty beside a 200 is that redaction, not a failed delivery.That redacted copy is what a SUCCESSFUL delivery stores. A delivery that failed keeps the real payload, so an operator can inspect and replay what was actually sent; a sweep replaces the payload of those rows — whole, with a marker object, not field by field — once they are more than 24 hours old. Inside that window GET /v1/webhooks/{id}/logs can return the submission in full on a number whose configuration says it keeps nothing.On such a number attachments are never re-hosted at all, so flowMedia is null on the delivery too — there is no stored copy to point at, and the reference stays where it was. Nor is a single exchange captured: submitted is null on the delivery as well, and not as a redacted row — no row at all. That is the number’s configuration doing what it says, not a failure.

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 in entry[].changes[].field.
message.received never fires on a Meta number, and events: ["*"] does not convert the format. The event name you subscribe to is Meta’s field name — messages — and the body you receive is Meta’s envelope. A receiver written against the canonical schema ({ event, data }) reads undefined out of a Meta payload and reports nothing, which looks exactly like “the customer never wrote”. Write the parser for the native shape, or branch on the number’s provider.
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 in events 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. See flow.* payload.
  • Normalized call events — call.ringing, call.connected, call.ended, call.missed, and call.permission_updated (Meta only). These are flat Pilot Status events, not Meta’s envelope — see call.* payload.
  • Number lifecycle — number.created, number.connected, number.removed, number.restored. number.disconnected is 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.
An event name the number’s provider cannot emit is dropped silently, not rejected. POST/PATCH filter events down to the provider’s subscribable set before storing — so subscribing a Meta number to message.received returns 201, stores an empty list, and delivers nothing. Read back the events of the created webhook and check it against what you sent.

Number health — subscribable since 2026-09-15

A Meta number never emits number.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.
They use the canonical { 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:
The array key is not stable. Meta puts the echoed messages under value.message_echoes on some payloads and reuses value.messages on others. Read both:
A parser that only reads one of them drops half the echoes with no error anywhere.

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 the correlationId. The correlationId is your own value and does not have to be unique. The id is unique.
  • Observed on sent, delivered and read, 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’s id and correlationId.

CTWA referral — where an ad click lands

There is no separate referral field to subscribe to. The block arrives inside the messages envelope, at entry[0].changes[0].value.messages[0].referral, verbatim.
referral is on the FIRST message of the conversation only. Meta does not repeat it on later messages, so a consumer that reads it per-message sees it once and must carry it forward itself.
  • source_id is 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 needs ads_read on the advertiser’s ad account, which a WhatsApp system-user token does not carry (it comes back GraphMethodException code 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: null means “not resolved yet, ask again”).
Meta can deliver the same wamid twice, with different content. Measured in production: a placeholder arrives first — type: "unsupported", errors[].code 131060 “This message is unavailable.”, no text and no referral — and the real message follows about 425 ms later with the text, the media and the referral.Both are delivered to your webhook. Deduplicating on the wamid alone discards the real one and leaves you with an empty bubble and no ad attribution. Key on (wamid, type) instead, and treat an unsupported + 131060 arrival as a placeholder that must never overwrite content you already stored.

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.
Both also carry the shared normalized 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: newsletterName is the channel display name when available; otherwise it may be omitted. The channel identifier is newsletterId (full ...@newsletter JID). participantName is the message author in the channel.
  • Media: on inbound media events, mediaLink is 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. mediaType is the category (image/video/audio/document/sticker), mediaCaption the accompanying text, mediaFilename the 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 to POST /v1/messages/send; use them for provisioning/monitoring. data.numberId is always the WhatsApp number ID on all of them — see the breaking change.
  • number.disconnected is 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 content may be empty; IDs and timestamps still exist.
  • The message.read event (and Read status in the API/Logs) only occurs when the recipient has WhatsApp read receipts enabled.