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