Webhooks
Webhooks are how you learn anything the API did not tell you synchronously: an inbound message, a delivery receipt, a failure. Deliveries are signed, retried with backoff, kept for inspection, and replayable.
Registering an endpoint
Register a URL and the event types you want. Naming * subscribes to everything, including types added after you subscribed — which is usually what you mean.
curl -X POST https://whats.azzamkh.sa/api/v1/webhook-endpoints \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/whatsapp",
"event_types": ["message.received", "message.delivered", "message.failed"],
"description": "Order service"
}'
# 201 Created — "secret" appears in THIS response and nowhere else.
# {
# "id": "wh_...",
# "url": "https://example.com/hooks/whatsapp",
# "event_types": ["message.received", "message.delivered", "message.failed"],
# "status": "active",
# "secret_last4": "4c1e",
# "secret": "..."
# }
# Subscribe to everything instead — including event types added later:
# "event_types": ["*"]- Managing endpoints requires the
webhooks.managescope, which is NOT in an API key's default scope set. Ask for it explicitly when you create the key. - The URL must be absolute, must not carry credentials, must use port 80 or 443, and must not resolve to a private address. A refusal is a 422 with the reason in
details.reason. - An endpoint is
activeorpaused; you cannot set it todisabled, because that is something the platform does to you. - The test endpoint sends a real delivery through the real pipeline, marked
is_test. No message is sent to anyone.
What a delivery looks like
A POST with a JSON body and six identifying headers.
POST /hooks/whatsapp HTTP/1.1
content-type: application/json
x-webhook-event-id: evt_...
x-webhook-delivery-id: whd_...
x-webhook-event: message.received
x-webhook-timestamp: 1786608000
x-webhook-attempt: 1
x-webhook-signature: v1=8f4c...
{
"id": "evt_...",
"type": "message.received",
"created_at": "2026-08-13T10:00:00.000Z",
"workspace_id": "ws_...",
"data": {
"object": "message",
"object_id": "msg_...",
"message_id": "msg_...",
"conversation_id": "cnv_...",
"contact_id": "cnt_...",
"channel_id": "ch_...",
"direction": "inbound",
"type": "text"
}
}Headers
| Header | Description |
|---|---|
| X-Webhook-Event-Id | The event's id. Stable across every retry and every replay — this is what you deduplicate on. |
| X-Webhook-Delivery-Id | This attempt's delivery row. Changes when you replay. |
| X-Webhook-Event | The event type, also present as `type` in the body. |
| X-Webhook-Timestamp | Unix seconds. Part of the signed payload. |
| X-Webhook-Attempt | Which attempt this is, starting at 1. |
| X-Webhook-Signature | One or more `v1=` entries. See below. |
The envelope
| Field | Description |
|---|---|
| id | The event id, matching the header. Deduplicate on this. |
| type | The event type. |
| created_at | When the event occurred, not when it was delivered. |
| workspace_id | The workspace the event belongs to. |
| data.object | The kind of thing the event is about, e.g. message. |
| data.object_id | Its public id. |
A status event carries a different data shape from an inbound message — it describes a transition rather than a message:
{
"id": "evt_...",
"type": "message.delivered",
"created_at": "2026-08-13T10:00:02.980Z",
"workspace_id": "ws_...",
"data": {
"object": "message",
"object_id": "msg_...",
"message_id": "msg_...",
"from_state": "sent",
"to_state": "delivered",
"category": "utility"
}
}
// On message.failed, "data" also carries:
// "failure_class", "error_code" and "reason" when the dispatcher set them.Verifying the signature
Every delivery is signed with HMAC-SHA256 over {timestamp}.{body}, presented as v1=<hex> in lowercase hexadecimal.
X-Webhook-Timestamp: 1786608000
X-Webhook-Signature: v1=8f4c…
signed_payload = "1786608000." + raw_request_body
signature = HMAC_SHA256(secret, signed_payload) # lowercase hex- Sign the
raw bytesyou received. Parsing the JSON and re-serialising it changes key order and whitespace, and the signature will not match. - Reject a timestamp more than
300seconds away from now, in either direction. This is what stops a captured delivery being replayed at you later. - Compare in constant time. A naive string comparison leaks the signature one byte at a time.
- The header can carry more than one entry. Accept the delivery if any of your active secrets matches any entry.
Verification, in full
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
/**
* @param rawBody the EXACT bytes received. Do not re-serialise the parsed
* JSON — key order and whitespace are part of the signature.
*/
export function verify(rawBody, headers, secrets) {
const timestamp = Number(headers["x-webhook-timestamp"]);
if (!Number.isFinite(timestamp)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const signed = `${timestamp}.${rawBody}`;
// The header may carry SEVERAL "v1=<hex>" entries during a secret
// rotation. Accept the delivery if ANY of your secrets matches ANY entry.
const presented = String(headers["x-webhook-signature"] ?? "")
.split(",")
.map((part) => part.trim())
.filter((part) => part.startsWith("v1="))
.map((part) => part.slice(3));
return secrets.some((secret) => {
const expected = crypto
.createHmac("sha256", secret)
.update(signed, "utf8")
.digest("hex");
return presented.some(
(candidate) =>
candidate.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)),
);
});
}Rotating the secret
Rotation returns a new secret and keeps the previous one signing for an overlap window of 24 hours. During it, every delivery carries two v1= entries — the new secret first — so you can deploy the new secret without dropping a single delivery.
curl -X POST https://whats.azzamkh.sa/api/v1/webhook-endpoints/wh_.../rotate-secret \
-H "Authorization: Bearer $WA_API_KEY"
# 200 OK — the new secret, once.
# {
# "id": "wh_...",
# "secret": "...",
# "secret_last4": "b217",
# "secret_rotated_at": "2026-08-13T10:00:00.000Z",
# "previous_secret_expires_at": "2026-08-14T10:00:00.000Z"
# }
# Until previous_secret_expires_at, deliveries are signed with BOTH secrets
# and x-webhook-signature carries two entries:
# x-webhook-signature: v1=<new>,v1=<old>Event catalogue
There are 31 event types. This is the complete list, read from the platform's own catalogue; the same list is served by GET /v1/webhook-event-types.
The inbound event is message.received
It is not message.inbound, and subscribing to a type that does not exist is a validation error rather than a silent no-op. If nothing is arriving, check the type first.
| Event | When it happens | Sent today |
|---|---|---|
| message.received | docs.pages.webhooks.events.list.message.received | Yes |
| message.queued | docs.pages.webhooks.events.list.message.queued | Yes |
| message.sent | docs.pages.webhooks.events.list.message.sent | Yes |
| message.delivered | docs.pages.webhooks.events.list.message.delivered | Yes |
| message.read | docs.pages.webhooks.events.list.message.read | Yes |
| message.failed | docs.pages.webhooks.events.list.message.failed | Yes |
| conversation.created | docs.pages.webhooks.events.list.conversation.created | Yes |
| conversation.assigned | docs.pages.webhooks.events.list.conversation.assigned | Yes |
| conversation.updated | docs.pages.webhooks.events.list.conversation.updated | Yes |
| conversation.closed | docs.pages.webhooks.events.list.conversation.closed | Yes |
| conversation.reopened | docs.pages.webhooks.events.list.conversation.reopened | Yes |
| conversation.note_added | docs.pages.webhooks.events.list.conversation.note_added | Yes |
| channel.created | docs.pages.webhooks.events.list.channel.created | Yes |
| channel.connected | docs.pages.webhooks.events.list.channel.connected | Yes |
| channel.disconnected | docs.pages.webhooks.events.list.channel.disconnected | Yes |
| channel.requires_repair | docs.pages.webhooks.events.list.channel.requires_repair | Yes |
| channel.archived | docs.pages.webhooks.events.list.channel.archived | Yes |
| contact.created | docs.pages.webhooks.events.list.contact.created | Yes |
| contact.updated | docs.pages.webhooks.events.list.contact.updated | Yes |
| contact.deleted | docs.pages.webhooks.events.list.contact.deleted | Yes |
| contact.import_completed | docs.pages.webhooks.events.list.contact.import_completed | Yes |
| template.created | docs.pages.webhooks.events.list.template.created | Yes |
| template.updated | docs.pages.webhooks.events.list.template.updated | Yes |
| template.archived | docs.pages.webhooks.events.list.template.archived | Yes |
| campaign.started | docs.pages.webhooks.events.list.campaign.started | Not yet |
| campaign.paused | docs.pages.webhooks.events.list.campaign.paused | Not yet |
| campaign.resumed | docs.pages.webhooks.events.list.campaign.resumed | Not yet |
| campaign.completed | docs.pages.webhooks.events.list.campaign.completed | Not yet |
| campaign.cancelled | docs.pages.webhooks.events.list.campaign.cancelled | Not yet |
| campaign.failed | docs.pages.webhooks.events.list.campaign.failed | Not yet |
| automation.failed | docs.pages.webhooks.events.list.automation.failed | Not yet |
The types marked as not sent yet can be subscribed to and validate correctly; nothing in the platform emits them. They are listed rather than hidden so a subscription written today does not have to change when they start arriving.
Retries and backoff
A delivery gets 6 attempts in total — one try and five retries — with a 10-second timeout each. Delays use full jitter, so a fleet of failed deliveries does not come back in a thundering herd.
| After attempt | Next attempt in |
|---|---|
| 1 | 5–10s |
| 2 | 10–20s |
| 3 | 20–40s |
| 4 | 40–80s |
| 5 | 80–160s |
What counts as a failure
| Response | Retried | failure_reason |
|---|---|---|
| 2xx | No | ok |
| 3xx | No | redirect |
| 429 | Yes | rate_limited |
| 4xx | No | client_error |
| 5xx | Yes | server_error |
| A connection, DNS or TLS error | Yes | network_error |
| No response within the timeout | Yes | timeout |
When the attempts are spent, the delivery ends as dead if the last failure was retryable, and failed if it was not. Redirects are never followed — return a 2xx from the URL you registered.
Repeated failure disables the endpoint
After 20 consecutive failed attempts the endpoint is disabled and stops receiving anything. One success resets the counter. To bring it back, fix the endpoint and set its status to active — which clears the counter as part of the same call.
Failure reasons
A delivery's failure_reason is one of ok, server_error, rate_limited, redirect, client_error, network_error, timeout, blocked_target, not_deliverable.
Inspecting and replaying
Every delivery is recorded with its attempts, its response status and its failure reason. Statuses are pending, delivering, succeeded, failed, dead, and deliveries are kept for 30 days.
# List what went out, and why it failed.
curl "https://whats.azzamkh.sa/api/v1/webhook-endpoints/wh_.../deliveries?status=failed" \
-H "Authorization: Bearer $WA_API_KEY"
# {
# "data": [{
# "id": "whd_...",
# "event_id": "evt_...",
# "event_type": "message.received",
# "status": "dead",
# "attempt_count": 6,
# "max_attempts": 6,
# "response_status": 500,
# "failure_reason": "server_error",
# "latency_ms": 812,
# "replay_count": 0
# }],
# "page": { "next_cursor": null, "has_more": false }
# }
# Send the SAME stored bytes again. The replay resets the delivery row;
# it does not create a new event.
curl -X POST https://whats.azzamkh.sa/api/v1/webhook-deliveries/whd_.../replay \
-H "Authorization: Bearer $WA_API_KEY"
# 202 AcceptedA replay is the same event, not a new one
Replaying resends the stored envelope byte for byte and resets the same delivery row — the event id does not change, so a correct consumer that deduplicates on it will recognise a replay it has already handled.
Writing a receiver
- Answer quickly, with any 2xx. Do the work afterwards: a slow handler turns into a timeout and a retry.
- Deduplicate on the
event id. Retries and replays are guaranteed to repeat it, and at-least-once delivery means you will see one eventually. - Do not assume order. A delivered event can arrive before the sent event it follows.
- Ignore event types you do not recognise rather than failing on them. New types are added, and a wildcard subscription will start receiving them.
- Return a 4xx only when the delivery is genuinely unacceptable — it is not retried. Return a 5xx to ask for a retry. See errors and limits for how the platform's own errors are shaped.