Skip to main content
Create a project webhook with 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.
The create response returns a generated signing secret once. Store it before discarding the response. List, retrieve, and update responses contain secret metadata only. Custom header values are write-only: responses return header names without their stored values. Rotate a secret with 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:
Send an 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, including campaign.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 Polymorfa id 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. Use webhook-deliveries:read with the team routes:
Add /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:
The request requires 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 through x-webhooks. Adding a bartender.* type to a webhook’s events returns 400; those types are not delivered. Common events include:
Use the schema associated with each 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. A format: "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 empty session. 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_opened requires 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.
A recipient held by 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 2xx only 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 and native for the complete Polymorfa event catalog.
The generated Platform API endpoint pages document the ownership-specific webhook CRUD and secret-rotation types. The Messaging API’s 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.