Webhooks
Webhooks let you receive real-time notifications when events happen. Register a URL and we send HTTP POST requests to it whenever a subscribed event occurs.
The same events are also pushed over a WebSocket connection — see the Realtime WebSocket guide. Webhooks are the guaranteed delivery channel (retries + logs); the WebSocket is the low-latency channel.
Event Types
| Event | Description |
|---|---|
message.received |
A message was received from a contact |
message.sent |
A message was sent to a contact |
message.status_updated |
A message status changed (delivered, read, failed) |
phone_number.connected |
A phone number was connected via onboarding |
phone_number.status_updated |
A phone number lifecycle event occurred (quality rating, messaging limit, ban state, name update, health state) |
* |
Subscribe to all events |
There are no
message.delivered,message.read, ormessage.failedevent types. All status changes (sent, delivered, read, failed) arrive as a singlemessage.status_updatedevent — filter bydata.status. Subscribing to a non-existent event name fails validation.
Create a Webhook
POST /v1/outgoing-webhooks
{
"url": "https://yourapp.com/webhooks/whatsapp",
"secret": "whsec_your_secret_key_min_16_chars",
"events": ["message.received", "message.sent"],
"isActive": true
}
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | Your HTTPS endpoint URL |
| secret | string | No | Signing secret for payload verification (16-256 chars) |
| events | string[] | Yes | At least one event type |
| isActive | boolean | No | Whether webhook is active (default: true) |
Manage Webhooks
| Operation | Endpoint |
|---|---|
| List | GET /v1/outgoing-webhooks |
| Get | GET /v1/outgoing-webhooks/{webhookId} |
| Update | PATCH /v1/outgoing-webhooks/{webhookId} |
| Delete | DELETE /v1/outgoing-webhooks/{webhookId} |
| Test | POST /v1/outgoing-webhooks/{webhookId}/test |
Delivery Retries
Webhook deliveries are attempted immediately. Retryable failures are retried automatically by the Worker cron: network errors, timeouts, HTTP 408, 425, 429, and 5xx responses. HTTP 2xx is success. HTTP 3xx final responses and other 4xx responses are treated as final failures.
Backoff schedule: 1 minute, 5 minutes, 15 minutes, then 1 hour. After the fifth failed attempt the
delivery is marked failed. Attempts are auditable through webhook log endpoints.
Payload Format
Every webhook delivery is an HTTP POST with a JSON body:
{
"event": "message.received",
"timestamp": "2026-03-03T15:30:00.000Z",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"messageId": "msg-uuid",
"conversationId": "conv-uuid",
"phoneNumberId": "phone-uuid",
"phoneNumber": "+15551234567",
"contactPhone": "5491155551234",
"contactName": "John Doe",
"contactId": "contact-uuid",
"contactIdentifier": "5491155551234",
"contactIdentifierType": "phone",
"contactReplyTo": "5491155551234",
"businessScopedUserId": null,
"parentBusinessScopedUserId": null,
"username": null,
"countryCode": null,
"direction": "inbound",
"type": "text",
"content": { "text": "Hi, I'd like to place an order" },
"wamid": "wamid.HBgLNTQ5MTE1NTU...",
"status": "delivered",
"externalId": "customer-123",
"lastInboundMessageAt": "2026-03-03T15:30:00.000Z",
"serviceWindowExpiresAt": "2026-03-04T15:30:00.000Z",
"error": null
}
}
Note that inbound text lives in
content.text— there is nocontent.bodyfield.
The externalId field is included in all message webhook events (message.received, message.sent, message.status_updated). It contains the same externalId set during onboarding link creation, or null if the phone was not onboarded via an onboarding link.
Use contactIdentifier as your stable contact key. Use contactReplyTo as the outbound to
value when replying. contactPhone is legacy display data and can be null for BSUID-only
contacts. See Migrating from Phone to Contact Identity.
data Field Reference (message events)
| Field | Type | Description |
|---|---|---|
| messageId | string | Internal message UUID (matches id in the messages API) |
| conversationId | string | Internal conversation UUID |
| phoneNumberId | string | Internal phone number UUID — use it in /v1/phones/{phoneId}/... calls |
| phoneNumber | string | Your WhatsApp business number, e.g. "+15551234567" |
| externalId | string | null | The externalId set during onboarding link creation |
| contactPhone | string | null | Contact's phone number (legacy display data, can be null) |
| contactName | string | null | Contact's WhatsApp profile name, if known |
| contactId | string | null | Internal contact UUID |
| contactIdentifier | string | Stable contact key — store this as your contact identifier |
| contactIdentifierType | string | "phone", "wa_id", "bsuid", or "username" |
| contactReplyTo | string | Use as the to value when sending a reply |
| direction | string | "inbound" or "outbound" |
| type | string | Message type: text, image, video, audio, document, sticker, interactive, button, location, contacts, reaction |
| content | object | Type-specific content (see table below) |
| wamid | string | WhatsApp message ID |
| status | string | undefined | Message status (sent, delivered, read, failed) |
| error | object | null | Raw Meta error object, present on failed statuses |
| lastInboundMessageAt | string | null | Timestamp of the contact's last inbound message at event time |
| serviceWindowExpiresAt | string | null | When the 24h customer service window closes (lastInboundMessageAt + 24h). Null when the window never opened — only templates can be sent. |
| referral | object | null | Click-to-WhatsApp ad attribution (only on message.received) |
Phone number format: contactPhone, contactIdentifier, and contactReplyTo carry the
WhatsApp ID (wa_id) exactly as Meta sends it — digits only, country code included, no +, and
no normalization applied by this API. For Argentina that includes the mobile 9
(5491155551234); for Mexico the 1 (5215512345678). Reply using the same value as the
outbound to — Meta accepts it directly.
content by Message Type (inbound)
data.type |
data.content fields |
|---|---|
text |
{ text } |
image |
{ mediaId, mimeType, caption?, mediaUrl, downloadUrl } |
video |
{ mediaId, mimeType, caption?, mediaUrl, downloadUrl } |
audio |
{ mediaId, mimeType, mediaUrl, downloadUrl } |
document |
{ mediaId, mimeType, filename?, caption?, mediaUrl, downloadUrl } |
sticker |
{ mediaId, mimeType, mediaUrl, downloadUrl } |
interactive (button reply) |
{ buttonId, buttonTitle, text } — text mirrors the button title |
interactive (list reply) |
{ listId, listTitle, listDescription?, text } — text mirrors the row title |
interactive (flow reply) |
{ flowName, flowBody, flowResponseJson, text } |
button (template quick reply) |
{ text, payload } |
location |
{ latitude, longitude, name?, address? } |
contacts |
{ contacts: [...] } — raw WhatsApp contact cards |
reaction |
{ reactionEmoji, reactedToMessageId } |
| unknown types | { raw } — the raw Meta message payload |
When the inbound message quotes (replies to) a previous message, content additionally includes
quotedMessageId, quotedMessageWamid, quotedMessageType, quotedMessagePreview, and
quotedMessageDirection.
Example — inbound document:
{
"event": "message.received",
"timestamp": "2026-03-03T15:30:00.000Z",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"messageId": "msg-uuid",
"conversationId": "conv-uuid",
"phoneNumberId": "phone-uuid",
"phoneNumber": "+15551234567",
"contactReplyTo": "5491155551234",
"direction": "inbound",
"type": "document",
"content": {
"mediaId": "1234567890",
"mimeType": "application/pdf",
"filename": "receipt.pdf",
"caption": "Here is my payment receipt",
"mediaUrl": "https://api.example.com/v1/phones/phone-uuid/media/1234567890",
"downloadUrl": "https://api.example.com/v1/phones/phone-uuid/media/1234567890/download"
},
"wamid": "wamid.HBgLNTQ5MTE1NTU..."
}
}
Example — inbound button reply (contact tapped an interactive button):
{
"event": "message.received",
"timestamp": "2026-03-03T15:31:00.000Z",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"messageId": "msg-uuid",
"conversationId": "conv-uuid",
"phoneNumberId": "phone-uuid",
"contactReplyTo": "5491155551234",
"direction": "inbound",
"type": "interactive",
"content": {
"buttonId": "btn_buy",
"buttonTitle": "Buy Now",
"text": "Buy Now"
},
"wamid": "wamid.HBgLNTQ5MTE1NTU..."
}
}
Media messages: For inbound media messages (image, video, audio, document, sticker), the content object includes both a metadata URL and a direct download URL:
{
"type": "image",
"content": {
"mediaId": "1234567890",
"mediaUrl": "https://api.example.com/v1/phones/phone-uuid/media/1234567890",
"downloadUrl": "https://api.example.com/v1/phones/phone-uuid/media/1234567890/download",
"caption": "Photo of the product"
}
}
Use downloadUrl to stream the actual file bytes.
Use mediaUrl when you need metadata (id, mimeType, fileSize, filename) plus the temporary Meta download URL.
Both endpoints require Authorization: Bearer <apiKey>.
Referral attribution (Click-to-WhatsApp ads / posts): When an inbound message originates from a Click-to-WhatsApp ad or a Facebook/Instagram post (e.g. the contact tapped a CTA), message.received includes a top-level referral object in data with the full attribution details:
{
"event": "message.received",
"data": {
"type": "text",
"content": { "text": "I saw your ad" },
"referral": {
"sourceUrl": "https://fb.me/abc123",
"sourceId": "120212345678901234",
"sourceType": "ad",
"headline": "Summer Sale - 50% off",
"body": "Message us to claim your discount",
"mediaType": "image",
"imageUrl": "https://scontent.example.com/ad-image.jpg",
"ctwaClid": "ARAkLkA8rmlFeiCktEJQ-QTwRiyYHAFDLMNDBH0CD3qpjd0HR4irJ6LEkR7BGFnGGnQjyTCxnsFqhrCW4Fef5dGGUO5MnpJWqcsKbQ"
}
}
}
| Field | Type | Description |
|---|---|---|
| sourceUrl | string | The Meta URL that leads to the ad or post |
| sourceId | string | The Meta ID for the ad or post |
| sourceType | string | "ad" or "post" |
| headline | string? | Headline used in the ad or post |
| body | string? | Body text of the ad or post |
| mediaType | string? | "image" or "video" |
| imageUrl | string? | URL of the ad image (when mediaType is "image") |
| videoUrl | string? | URL of the ad video (when mediaType is "video") |
| thumbnailUrl | string? | URL of the ad thumbnail |
| ctwaClid | string? | Click ID generated by Meta for Click-to-WhatsApp ads — use it for Conversions API attribution |
data.referral is null for messages without referral attribution. The same data is also embedded in data.content.referral (plus flattened content.referralSourceType, content.referralSourceId, content.referralHeadline, content.referralCtwaClid fields) for backward compatibility.
Messages sent from the WhatsApp Business app (coexistence): When a phone number is connected in coexistence mode, the business can also reply from the WhatsApp Business app on their phone. Those messages are synced into the conversation as outbound messages and delivered to subscribers as message.sent events. They are distinguishable from API sends by content.source:
{
"event": "message.sent",
"data": {
"direction": "outbound",
"type": "text",
"content": {
"text": "I'll get back to you shortly",
"source": "whatsapp_business_app"
},
"status": "sent"
}
}
Notes on app-sent messages:
- Meta echoes text, image, video and document messages. Messages sent from unsupported companion clients (e.g. WhatsApp for Windows) are not echoed by Meta and cannot be synced.
- Meta does not document delivery/read status webhooks for app-sent messages, so their status remains
sent. - When the business deletes a message from the app, the original message is soft-deleted locally (hidden from message listings). When the business edits a message, the stored content is updated in place and the message exposes an
editedAttimestamp in the messages API. - No webhook event is emitted for app-side edits or deletes — consumers that cache messages should re-fetch via
GET /v1/phones/{phoneId}/conversations/{conversationId}/messagesif they need to reflect them.
Message Status Updates (message.status_updated)
A single event type covers every status change of an outbound message. Filter by data.status:
sent, delivered, read, or failed. Statuses only ever advance — Meta does not guarantee
webhook ordering, and out-of-order deliveries never move a message backwards.
Example — delivered:
{
"event": "message.status_updated",
"timestamp": "2026-03-03T15:32:00.000Z",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"messageId": "msg-uuid",
"conversationId": "conv-uuid",
"phoneNumberId": "phone-uuid",
"contactReplyTo": "5491155551234",
"direction": "outbound",
"type": "text",
"content": { "text": "Your order is ready for pickup" },
"wamid": "wamid.HBgLNTQ5MTE1NTU...",
"status": "delivered"
}
}
Example — failed (message sent outside the 24-hour service window):
{
"event": "message.status_updated",
"timestamp": "2026-03-03T15:33:00.000Z",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"messageId": "msg-uuid",
"conversationId": "conv-uuid",
"phoneNumberId": "phone-uuid",
"contactReplyTo": "5491155551234",
"direction": "outbound",
"type": "text",
"content": { "text": "Hello again!" },
"wamid": "wamid.HBgLNTQ5MTE1NTU...",
"status": "failed",
"error": {
"code": 131047,
"title": "Re-engagement message",
"message": "Re-engagement message",
"error_data": {
"details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number."
}
}
}
}
data.error is the raw Meta error object from the status webhook: { code, title, message?, error_data?: { details } }.
Important: a successful send response (200 + wamid) only means Meta accepted the
message. Some failures surface asynchronously — outside the 24-hour window, undeliverable
recipient, spam limits. If delivery matters to your integration, subscribe to
message.status_updated and treat data.status: "failed" as the source of truth. See
Sending Messages — Error Handling for the full error model and
common Meta error codes.
Recovering from Missed Webhooks
If your system suspects it missed webhook deliveries, fetch the phone health snapshot:
curl https://api.example.com/v1/phones/{phoneId}/health \
-H "Authorization: Bearer sk_org_..."
This returns the current registration state plus data.capabilities, and also kicks off an asynchronous resync against Meta. Use it as your first recovery step before alerting or disabling a phone.
phone_number.connected Payload
{
"event": "phone_number.connected",
"timestamp": "2024-01-15T10:35:00.000Z",
"organizationId": "org_uuid",
"data": {
"onboardingLinkId": "link_uuid",
"externalId": "customer-123",
"phoneNumberId": "phone_uuid",
"phoneNumber": "+5491155551234",
"wabaId": "waba_uuid",
"metaWabaId": "meta_waba_id",
"isPhoneRegistered": true
}
}
Phone Lifecycle Events (phone_number.status_updated)
Every phone lifecycle event we record is also delivered in real time through the phone_number.status_updated webhook event. This is the same event stream exposed by GET /v1/phones/{phoneId}/events, so you can react to phone changes without polling.
A single webhook event type covers the whole lifecycle. Consumers filter by data.event to distinguish between lifecycle events:
data.event |
Source | Description |
|---|---|---|
PHONE_NUMBER_QUALITY_UPDATE |
Meta webhook | Quality rating or messaging limit changed |
BAN_STATE_CHANGED |
Meta webhook | The WABA ban state changed |
PHONE_NUMBER_NAME_UPDATE |
Meta webhook | The phone's display name review was updated |
PHONE_HEALTH_SNAPSHOT |
Health resync | First health snapshot recorded for the phone |
PHONE_HEALTH_STATE_CHANGED |
Health resync | Registration, status, quality, messaging limit, or messaging health changed since the last snapshot |
Other account_update events from Meta are forwarded with their original event name, so new lifecycle events flow through automatically.
phone_number.status_updated Payload
{
"event": "phone_number.status_updated",
"timestamp": "2026-03-23T18:00:00.000Z",
"organizationId": "org_uuid",
"data": {
"phoneEventId": "event_uuid",
"phoneNumberId": "phone_uuid",
"phoneNumber": "+5491155551234",
"externalId": "customer-123",
"wabaId": "waba_uuid",
"metaWabaId": "meta_waba_id",
"event": "PHONE_HEALTH_STATE_CHANGED",
"eventData": {
"source": "graph_resync",
"previous": { "qualityRating": "GREEN", "messagingLimit": "TIER_1K" },
"current": { "qualityRating": "YELLOW", "messagingLimit": "TIER_1K" },
"changes": [
{ "field": "qualityRating", "previous": "GREEN", "current": "YELLOW" }
]
},
"occurredAt": "2026-03-23T18:00:00.000Z"
}
}
| Field | Type | Description |
|---|---|---|
| phoneEventId | string | ID of the stored phone event; matches entries from GET /v1/phones/{phoneId}/events |
| phoneNumberId | string | Internal phone number ID |
| phoneNumber | string | The phone number string |
| externalId | string | null | The externalId set during onboarding, for routing |
| wabaId | string | Internal WABA ID |
| metaWabaId | string | Meta WABA ID |
| event | string | Lifecycle event name (see table above) |
| eventData | object | null | Event-specific details as stored in the phone event log |
| occurredAt | string | ISO 8601 timestamp of when the event was recorded |
The eventData shape depends on data.event: Meta-sourced events include the raw update info from Meta (qualityUpdate, banState, nameUpdate, wabaInfo, scope), while health resync events include previous/current snapshots and a changes array.
HTTP Headers
Every delivery includes:
| Header | Description |
|---|---|
Content-Type |
application/json |
User-Agent |
WhatsApp-CloudAPI-Webhook/1.0 |
X-Webhook-Event |
The event type |
X-Webhook-Timestamp |
ISO 8601 timestamp |
X-Webhook-Delivery |
Unique delivery ID (for deduplication) |
X-Webhook-Signature-256 |
HMAC-SHA256 signature (only if secret is set) |
Verifying Signatures
If you set a secret, verify the X-Webhook-Signature-256 header:
- Compute HMAC-SHA256 of the raw request body using your secret
- Compare with the header value (after removing the
sha256=prefix) - Use constant-time comparison to prevent timing attacks
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const received = signatureHeader.replace("sha256=", "");
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"));
}
Python:
import hmac, hashlib
def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.replace("sha256=", ""))
What Your Endpoint Should Return
- Return a 2xx status code to acknowledge receipt
- Respond within 30 seconds
- Any non-2xx response or timeout is treated as a failed delivery
Inspect failures via GET /v1/outgoing-webhooks/{webhookId}/logs.