POST /platform/projects/{projectId}/webhooks, or a
team webhook with POST /platform/webhooks. Project webhooks receive project
events. Team webhooks receive customer lifecycle and campaign lifecycle events. The two ownership scopes are separate: a project credential
cannot read or mutate team webhooks.
Use webhooks:read for list and retrieve operations. Use webhooks:manage for
create, update, delete, and secret rotation. Every mutation requires an
Idempotency-Key header.
POST /platform/projects/{projectId}/webhooks/{webhookId}/secret-rotations, or use
the equivalent team route without the project prefix. The response
returns the new secret once. The previous secret remains valid for the returned
overlap period. Replaying the same idempotent request returns the receipt but
does not reveal secret material again. Updating webhook settings never rotates
the secret.
Send a test delivery
Use the owner-matching test route to create a synthetic event and a real, traceable delivery:Idempotency-Key header. Both routes accept an optional eventType.
Only the project route accepts an exact base64-encoded native event body. That
body requires a sessionId for an HMS-enabled session in the same project. The
team route rejects body and sessionId with HTTP 400. The project
route also rejects either field when its matching field is absent. When
eventType and a body are both present, the eventType must equal the event
discriminator inside the body. A mismatch is rejected before any event or
delivery is created. Names reserved for internal use are rejected with HTTP 400
in both generated tests and supplied event bodies.
The response immediately identifies the synthetic event, delivery, operation,
and idempotency receipt. It does not wait for delivery to finish.
In the TypeScript SDK, use client.webhooks.test for team endpoints and
client.project(projectId).webhooks.test for project endpoints. The methods
accept different request types so a team test cannot include body or
sessionId. See TypeScript SDK.
In the CLI, pass --event to polymorfa webhook test. A team test accepts
that event type only. For a project test with a supplied native event body,
pass --input with JSON containing both body and sessionId; the CLI
rejects either field on its own.
Manage webhooks in the Console
Select a project and open Settings → Webhooks to manage endpoints for its message, number, call, and other session events. To receive customer and campaign lifecycle events, open Settings → Webhooks in the all-projects team view. Team endpoints do not receive project events. Both views let you list, add, edit, enable, disable, or delete webhook endpoints. Every team member can view the endpoint list. Only team owners and admins can add, edit, enable, disable, rotate secrets for, or delete endpoints. Each endpoint supports a public URL, an event selection, native or Meta-compatible format, and a retry policy. Project endpoints also have a Phone offline warning section: turn Send warnings on or off and drag First warning to choose the day. See Phone offline warnings. Search the event selector by name or exact event type. Select visible adds all events matching the search without removing existing selections. Clear selection removes the selected types. Project endpoints and team endpoints with All team events turned off require at least one selected event. Customer lifecycle events and campaign lifecycle events, includingcampaign.recipient_failed, belong to the team. Project campaign events,
including campaign.recipient_sent, campaign.recipient_delivered,
campaign.recipient_read, campaign.recipient_replied,
campaign.recipient_link_opened, and campaign.throttled, belong to the
sending number’s project. Each selector lists events for its own scope. Expand an event prefix, such as message or call, and tick the events you want to receive. Search opens matching groups. The endpoint form scrolls as one area while its action buttons remain visible. Previously
saved types outside that catalog remain visible and are preserved until you
remove them, but they never match a delivery. New subscriptions to names reserved
for internal use are rejected with HTTP 400. An endpoint that receives all
events receives every documented event type and nothing else.
Project endpoints require at least one selected event. Their maximum attempts
range from 1 to 10, and initial delay ranges from 1 to 3600 seconds.
Team endpoints accept retry attempts from 0 to 10 and delay from 0 to
3600 seconds. Choose All team events to receive every team event, or
turn it off and select individual types.
Policies are constant, linear, or exponential.
When you create a project endpoint, save the signing secret shown in the
confirmation dialog before closing it. Editing the endpoint preserves that
secret. The Console never displays a previously stored signing secret.
Delivery formats
Both signatures are HMAC-SHA256 over the exact raw HTTP body using the webhook
signing secret.
Compare in constant time before parsing JSON. The signature is absent when no
key is configured.
Retry configuration
Native message events carry a Polymorfaid and the exact provider
whatsapp_ids object. Its linked_devices and official_api keys contain
observed provider references; unknown keys are omitted. Conversation and sender objects contain id with optional
phoneNumber, bsuid, and username. A message.ack event identifies its
conversation and a messages array of { id, whatsapp_ids } references. Match receipts using the Polymorfa message
ID, scoped to the Number that emitted the event.
Meta-format deliveries retain the Meta message identifiers and envelope. They
carry business-scoped user IDs as user_id, from_user_id, and
recipient_user_id, and usernames under contacts[].profile.username. Phone
fields such as wa_id, from, and recipient_id are omitted when WhatsApp
does not supply the phone number.
When WhatsApp supplies parent BSUIDs, native identity objects include
parentBsuid. Meta-format deliveries preserve contacts[].parent_user_id,
messages[].from_parent_user_id, and statuses[].recipient_parent_user_id.
Identity-change notifications become native contact.update events with
identityChange, or Meta-format messages with type: "system" and
system.type: "user_changed_number" or "user_changed_user_id". Subscribe to
messages at Meta; user_id_update is not a subscribable webhook field.
See User identities.
Do not treat a native event ID, a Polymorfa message ID, and a Meta message ID as
the same identifier.
retryPolicy.maximumAttempts, retryPolicy.initialDelaySeconds, and
retryPolicy.backoff are stored per webhook. Policies are constant, linear,
or exponential.
Automatic delivery stops at the smaller of maximumAttempts and eight total
attempts, including the initial request. Set maximumAttempts to 1 to disable
automatic retries. Automatic retry timing follows fixed offsets from the first
attempt: 30 seconds, 2 minutes, 10 minutes, 1 hour, 4 hours, 12 hours, and 24 hours.
The saved initial delay and backoff do not change that automatic schedule.
Queueing and processing can delay an attempt beyond its scheduled time.
Webhook delivery is at least once. A delivery can produce more than one HTTP
request when an acknowledgement is lost or an attempt is retried. Store the
event identifier with your accepted work and ignore a duplicate identifier.
Each message event is delivered separately. Delivery order across events is not
guaranteed; process each event by its ID and timestamp. Per-conversation batching
is not an endpoint setting.
Retries and event replays can arrive between newer events. An automatic or
manual retry keeps the original event and logical delivery IDs. Replaying an
event keeps its event ID and creates a new logical delivery ID.
Inspect deliveries and attempts
A delivery is the logical event-to-webhook delivery. An attempt is one physical HTTP request. Automatic retries append immutable attempts to the same delivery; they do not create another logical delivery. Usewebhook-deliveries:read with the team routes:
/platform/projects/{projectId} before /webhook-deliveries for project-owned
webhooks. Team routes exclude project deliveries, and project routes
return only deliveries owned by the exact project.
Filter delivery lists by webhookId, eventId, status, since, or exclusive
until. Status is one of pending, delivering, retrying, succeeded, or
failed. List routes use opaque owner- and filter-bound cursors with a maximum
page size of 100.
Delivery records expose safe status, attempt count, timestamps, payload
availability, and a redacted last outcome. Attempt records expose their number,
timing, HTTP status code, and stable error code. Failed HTTP attempts also
include the response content type and a redacted response excerpt of up to 8
KB. Bodies with an invalid content type or a content type longer than 128 bytes
are omitted. Polymorfa does not store response bodies from successful deliveries or
network errors. Neither resource returns the request body, header values,
signing secret, IP address, DNS result, or raw network error.
In the Console, open a webhook delivery from the project’s Logs page to see
the event type, webhook format, environment, source number, payload
availability, and every recorded attempt. The detail view loads the linked
native event envelope when that payload is still available. For a native-format
webhook, that envelope is the delivered request body. Meta-format request bodies
are translated during delivery and are not reconstructed later. Message-bearing
event payloads are retained only when hosted message storage is enabled for the
exact source number. If the payload was not retained or has expired, the Console
says so and still shows the delivery and attempt metadata.
Retry a failed delivery with its owner-matching route:
webhook-deliveries:retry and an Idempotency-Key
header. It has no body. The server rechecks capabilities.retryable and exact
payload availability in the mutation transaction. An accepted retry appends a
new immutable attempt to the same delivery and immediately returns the
delivery ID, new attempt ID, asynchronous operation ID, and idempotency
receipt. It never creates another logical delivery and never waits for the
attempt to complete.
When a webhook exhausts its retry policy, Polymorfa creates one failure notice
for that webhook. Later terminal failures update the same active incident
instead of sending duplicate notices. A successful delivery closes the
incident; disabling or deleting the webhook closes it without reporting a
recovery.
After five failed attempts in a row to one webhook, Polymorfa pauses
deliveries to it for five minutes. During the pause, new deliveries fail with
webhook.circuit_open without contacting the endpoint, and a failed attempt
ends its delivery instead of scheduling another retry. After five minutes, the
next delivery is sent. A successful delivery ends the pause; another failure
pauses deliveries for five more minutes. Once the endpoint accepts deliveries
again, retry the failed deliveries or replay
their events.
Every active team member gets a Console notification when an incident opens,
when deliveries pause, and when a successful delivery closes an incident.
Members who have email turned on for Webhook delivery problems in
Team settings > My Notifications also get an email for each:
When the same failure opens an incident and pauses deliveries, you get the
pause email only. Disabling or deleting a webhook closes its incident without a
recovery email. Each email links to the webhook’s Console settings. None of
them includes an event body, response body, secret, or custom header value.
See Event history to inspect the event associated with a
delivery.
Current event catalog
The Messaging API OpenAPI document publishes all payload types throughx-webhooks.
Adding a bartender.* type to a webhook’s events returns 400; those types
are not delivered.
Common events include:
x-webhooks operation for the exact
payload. Common message fields include typed IdentityRef, media metadata, reaction,
poll, edit, and unavailable-state fields; session, group, contact, call, and
command events each have their own named component.
Linked Devices message.received, message.update, and message.sent
payloads also carry optional display fields: width, height, seconds,
waveform, pageCount, an embedded JPEG thumbnail, a text message’s
linkPreview, and, on the HD copy of a photo or video, association and
quality. See Media previews and HD images.
For an inbound Official API message, message.received.payload.referral carries
Meta-supplied source_type, source_id, source_url, and ctwa_clid when
present. This identifies the message’s entry point. It does not establish a
conversion, order payment, or revenue. The same referral object appears on a
Meta-format message webhook.
When a contact sends a cart from a catalog, product or product list message to
an Official API Number, message.received has type: "order" and
payload.order holds the cart exactly as Meta reported it: catalogId,
optional text, and items, each with productRetailerId, quantity,
itemPrice and currency. Retailer IDs accept at most 256 UTF-8 bytes and
no control characters. There is no order total. Multiply quantity by
itemPrice for each item if you need one, and confirm prices and stock in your
own system before you fulfil the order. order is omitted when any item cannot
be read; the message is still delivered. Meta-format webhooks carry Meta’s
order object, and a cart that cannot be read arrives as type: "unsupported".
With hosted message history on, history messages return the same order.
When a contact taps a reply button or picks a list row, message.received
carries payload.replyChoice with the control kind (button or list) and
the id you assigned when you sent it. Match on id, not on the visible
label. The one exception is a template quick-reply button sent without a
payload: WhatsApp then returns the button text as the id. Linked-account
messages set type to button_reply or list_reply. Official API messages
keep Meta’s type, interactive, and button objects alongside
replyChoice. payload.parentMessageId holds the WhatsApp ID of the quoted
message; it is not necessarily a message you sent. Official API messages carry
it whenever Meta supplies a quoted message. Linked-account messages carry it on
text replies, button and list replies, and Flow form responses. IDs are dropped, never shortened, when they are malformed or longer than
256 bytes (replyChoice.id) or 128 bytes (parentMessageId), or when they
contain control characters such as tabs or line breaks. Use printable button
and list row IDs when you send them. A linked-account
button or list reply without a valid ID is delivered as type: "unknown".
In Meta-format webhooks, a linked-account reply becomes an interactive
message whose button_reply or list_reply object carries only id, without
title or description, and whose context carries only id.
Every user-valued IdentityRef uses the stable id as its stable key.
phoneNumber and username are optional aliases and can be present together.
Upsert users by id; do not create separate records for aliases. See
User identities.
presence.update is emitted only when the session’s effective presence or
typing observation mode is events or cache. Its observedAt field is the
Unix timestamp when the runner accepted the WhatsApp wire event.
The event is not durable presence storage: Polymorfa does not write each presence
transition to PostgreSQL, and the optional lookup cache is bounded and
in-memory. See Linked-device controls
for modes, subscription limits, and read semantics.
The three call.participant_* events report live group-call roster changes.
audioMuted and video remain false because WhatsApp does not expose those
states per participant. The browser lifecycle socket uses an ephemeral feed:
after a disconnect it resumes with new events and does not replay changes it
missed while offline.
labels.update identifies the change through action. A label_edit payload
includes labelId, observedAt, and the observed definition fields name,
color, orderIndex, and deleted. Chat-association changes include from,
labelId, labeled, and observedAt. Star changes include from,
messageId, and starred. These action-specific fields are optional in the shared payload because
one event never represents every action at once.
business.quick_reply.update is a best-effort app-state event for a created,
replaced, or deleted WhatsApp Business App quick reply. It contains the quick
reply ID, shortcut, message, keywords, count, associated label IDs, observation
timestamp, and deletion flag. The default events mode retains no content.
Projects that select cache can read a bounded in-memory projection; off
suppresses the webhook and clears that projection. See
Quick-reply observation.
BanSafe events
Four public events report number safety. They are session-scoped and delivered in native format. Aformat: "meta" webhook does not receive them. Safety
payloads identify your own sending session and do not contain message content,
message IDs, recipient IDs, or chat history.
Polymorfa does not send
bansafe.risk_changed, bansafe.health_changed, or
bansafe.enforcement. New subscriptions cannot select them. An endpoint that
already lists one keeps its other events; you can remove the old name when you
edit it. Use bansafe.incident for restrictions WhatsApp applies,
bansafe.health_threshold for Health alerts, and the
BanSafe API to read a number’s current state.
bansafe.health_threshold carries sessionId, projectId, health,
threshold, healthSource, estimatorVersion, nullable modelVersion,
evaluatedAt, policyVersion, episodeId, and actionId. Rules-v1 events
set modelVersion to null. The webhook envelope ID is the stable action ID, so retries can be
deduplicated by envelope ID. An unavailable or stale Health estimate does not
produce this event.
bansafe.action describes an action, not a Health estimate. For a daily
allowance notice it uses action: "daily_allowance_reached" and
reason: "warmup"; its rung does not change and eligibleLiftAt gives the
next reset.
bansafe.incident is the durable record of a restriction WhatsApp applied
or you reported. It arrives once when the record is created and again when it
settles; kind and source say what happened and who observed it.
bansafe.claim reports the lifecycle of a Ban Insurance claim.
See Restrictions,
Warm-up plan, and
Ban Insurance.
Number restrictions and logouts
session.restriction_updated reports a WhatsApp restriction on the number
starting, changing, or ending. Today it carries one type:
reachout_timelock, WhatsApp’s temporary limit on starting conversations and
calls with people the number has not chatted with.
While the restriction is active, messages and calls to people the number has
already chatted with continue. A call to anyone else is refused with
number_restricted; see
Calls.
session.logged_out carries why the number was logged out:
code is WhatsApp’s own logout code, or 0 when it gave none. Reconnect a
device_removed number by pairing it again; a banned number needs a new
number.
Code 403 arrives as unknown because WhatsApp uses it both when it locks an
account and when the phone’s own account moves. Polymorfa treats it as a ban
for safety: it stops sending from the number and records the outcome in
Safety. Check the number in WhatsApp on the
phone; pair it again if the account is intact.
Phone offline warnings
WhatsApp signs a linked device out after 14 days without its phone.session.phone_offline warns you before that happens. It arrives once a day
while the phone stays offline and stops when the phone is seen again.
Each webhook chooses the first day it receives the warning with
phoneOfflineStartDay, from 7 to 13. The default is 10. A webhook set to
7 receives up to seven warnings before the sign-out. One set to 13 receives
one. Set the day when you create or update a webhook, or in the Phone offline
warning section of a project endpoint in the Console. Turning Send
warnings off removes the event from the endpoint.
To stop the warnings, remove
session.phone_offline from the webhook’s
events. A webhook that receives all events needs an explicit event list
without session.phone_offline. That webhook then no longer receives event
types added later. On the Messaging API, an empty events list means all
events, so a webhook whose only event is session.phone_offline cannot drop it.
Disable or delete that webhook instead.
Ban outcomes on session.status
session.status with statusReason: "TEMPORARY_BAN" also carries banCode,
banReason, and banExpiresAt. banExpiresAt is a Unix timestamp in
seconds and is present only when WhatsApp gives a duration. detail
carries extra context for FAILED and STREAM_ERROR. These four fields are
now part of the published payload schema; a client that ignored them before is
unaffected.
Send failures
message.failed reports an outbound send that failed, was refused before
transport, or has an outcome that Polymorfa cannot safely repeat. Its error
field is a sanitized class:
For a classified refusal or unknown outcome, the payload carries
code with
the stable error code the synchronous request would have received:
retryAfter is present on a paced send, an exhausted daily allowance, and a
temporary allowance-accounting pause. It is absent on
bansafe_send_outcome_unknown.
This is what tells a caller using Prefer: respond-async, which receives 202
immediately, why a message never went out. See
Errors.
Campaign events
Seventeen events report a campaign. The launch, reschedule, pause, resume, stop, completion, and failure events are campaign-scoped and arrive with an emptysession.
Recipient and pacing events reported by a send carry the sending number in
session. A campaign.recipient_skipped with reason
whatsapp_marketing_limit that WhatsApp reports after the send was accepted
arrives with an empty session.
- The three recipient delivery, read, and reply events require team enrollment in the recipient engagement events beta and an enabled rollout. Campaign counters continue to update when the event release is unavailable. Events from before enrollment are not sent later.
- Delivery, read, or reply transitions for sends made before this event release are not emitted; campaign counters still update.
- While a team is enrolled but the rollout is paused, events wait and are sent once it resumes. An event still unsent after seven days is dropped. Withdrawing from the beta drops unsent events.
campaign.recipient_link_openedrequires team enrollment in the campaign link tracking beta and an enabled rollout, and follows the same waiting and dropping rules. It carries no phone number, link, destination, IP address, or user agent. Each recipient produces at most one event per tracked link.- Events for one recipient are not guaranteed to arrive in order. Use each payload’s timestamp.
campaign.cold_blocked is counted once in skippedCount.
Polymorfa does not attempt that recipient again and does not count the hold as
a failure, so a list of people who have never written to you leaves
failedCount at zero. To message them anyway, set
senderConfig.throughput.coldSendPolicy to allow. See
Campaigns and BanSafe.
campaign.paused carries one of these codes when Polymorfa paused the
campaign:
Resume the campaign once the cause is resolved. See
Campaigns and number safety.
campaign.throttled and campaign.cap_reached are pacing signals, not
failures. Both are rate-limited: campaign.throttled at most once per campaign
per cooldown window, and campaign.cap_reached once per number per cap window.
campaign.throttled carries reason, naming what held the pass back.
paced means no number in the campaign’s own pool could send this pass. Once
automatic restrictions are enabled, bansafe_throttled means a restriction on
the sending number paced it. new_chat_limit_reached means WhatsApp capped the
sending number’s new chats for the current cycle; see
WhatsApp new-chat cap. Either way the recipient is queued again and
retried, no delivery attempt is spent, and failedCount does not move. See
Campaigns and number safety.
Opt-out changes
contact.opted_out and contact.opted_in report a contact who changed your
team’s opt-out list by replying to a campaign message with a configured keyword.
The reply body is matched in memory and never stored, so neither event carries
the message. Changes you make through the opt-out API or the Console do not
produce these events. See
Opt-outs and STOP replies.
Template updates
template.status reports what WhatsApp did to one of your Cloud API message
templates. kind is message_template_status_update for an approval change
and message_template_quality_update for a quality change. event carries the
new status and reason carries WhatsApp’s explanation when it gives one. A
quality change carries previousQualityScore and newQualityScore, and its
event is the new quality score.
templateId (a string), templateName, language, and wabaId identify the
template and the WhatsApp Business Account that owns it. Only kind is
guaranteed; the other fields are present when WhatsApp sends them.
WhatsApp Business Account notifications (account_alerts and
account_update) arrive as session.status with source: "meta", kind,
wabaId, and value, the notification’s value object exactly as WhatsApp
sent it. A display-name review notification uses the same native event with
kind: "phone_number_name_update". Its value includes
display_phone_number, decision, requested_verified_name, and, when Meta
provides it, rejection_reason. It reaches only the matching Number under the
account. These events carry no status field, so check source before reading
status. A format: "meta" webhook receives each notification on its original
field. It does not receive connection-state session.status events. In the
format: "meta" envelope of an account, display-name, or template
notification, entry.id is the WhatsApp Business Account that sent it, even if
the number has since moved to another account, and entry.time is the event
timestamp in Unix seconds.
Account and template changes belong to the WhatsApp Business Account, so each
Cloud API number under that account receives its own copy with its own event
id. Message, status, and display-name changes reach only the Number they
identify.
WhatsApp redelivers a notification until Polymorfa accepts it. A redelivery
of the same notification to a number produces the same event id, and
Polymorfa normally suppresses the repeat. Two separate changes, such as a
template that is paused and later approved again, produce different IDs. An
account or template notification that arrives without a WhatsApp time is
always published, so its redeliveries can arrive with different IDs. Delivery
is at least once, so deduplicate on id. When WhatsApp reports a time
for a template, account, Business app echo, contact sync, or history sync
change, the event timestamp is that time rather than the time Polymorfa
received the notification.
Receiver rules
- Return any
2xxonly after the event is durably accepted. - Deduplicate with the event identifier from the delivered envelope.
- Expect retries and possible cross-webhook reordering.
- Do not log raw bodies or signature secrets; message payloads may contain customer content.
- Use
format: "meta"when migrating an existing Meta webhook consumer andnativefor the complete Polymorfa event catalog.
x-webhooks
section documents delivered event bodies.
Number capability changes
session.capabilities_updated is not offered. Enrollment in the
number-capabilities beta does not enable
this extension. Its payload
contains session, projectId, status, syncedAt, checkedAt, accountType,
capabilities, and changedKeys. changedKeys lists capabilities whose value
or source changed; account-type or status transitions also produce an event.
Routine checks produce no event. The payload contains no raw configuration or
experiment identifiers. Group capability changes are excluded.
Eligibility is checked before publication and delivery. Queued events wait when
access is unavailable and expire after seven days. Deduplicate retries by event
ID. A Meta-format endpoint has no mapping for this event; use native format.