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, or message.failed event types. All status changes (sent, delivered, read, failed) arrive as a single message.status_updated event — filter by data.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 no content.body field.

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:


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:

  1. Compute HMAC-SHA256 of the raw request body using your secret
  2. Compare with the header value (after removing the sha256= prefix)
  3. 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

Inspect failures via GET /v1/outgoing-webhooks/{webhookId}/logs.