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.


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
* Subscribe to all events

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": { "body": "Hi, I'd like to place an order" },
    "wamid": "wamid.HBgLNTQ5MTE1NTU...",
    "status": "delivered",
    "externalId": "customer-123",
    "error": null
  }
}

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.

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>.


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
  }
}

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.