Skip to content
Contents
Sending and receiving

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

GET/v1/webhook-event-typesPOST/v1/webhook-endpointsGET/v1/webhook-endpointsGET/v1/webhook-endpoints/{endpointId}PATCH/v1/webhook-endpoints/{endpointId}DELETE/v1/webhook-endpoints/{endpointId}POST/v1/webhook-endpoints/{endpointId}/rotate-secretPOST/v1/webhook-endpoints/{endpointId}/test

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
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.manage scope, 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 active or paused; you cannot set it to disabled, 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.

HTTP
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

HeaderDescription
X-Webhook-Event-IdThe event's id. Stable across every retry and every replay — this is what you deduplicate on.
X-Webhook-Delivery-IdThis attempt's delivery row. Changes when you replay.
X-Webhook-EventThe event type, also present as `type` in the body.
X-Webhook-TimestampUnix seconds. Part of the signed payload.
X-Webhook-AttemptWhich attempt this is, starting at 1.
X-Webhook-SignatureOne or more `v1=` entries. See below.

The envelope

FieldDescription
idThe event id, matching the header. Deduplicate on this.
typeThe event type.
created_atWhen the event occurred, not when it was delivered.
workspace_idThe workspace the event belongs to.
data.objectThe kind of thing the event is about, e.g. message.
data.object_idIts public id.

A status event carries a different data shape from an inbound message — it describes a transition rather than a message:

json
{
  "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.

scheme
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 bytes you received. Parsing the JSON and re-serialising it changes key order and whitespace, and the signature will not match.
  • Reject a timestamp more than 300 seconds 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
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.

EventWhen it happensSent today
message.receiveddocs.pages.webhooks.events.list.message.receivedYes
message.queueddocs.pages.webhooks.events.list.message.queuedYes
message.sentdocs.pages.webhooks.events.list.message.sentYes
message.delivereddocs.pages.webhooks.events.list.message.deliveredYes
message.readdocs.pages.webhooks.events.list.message.readYes
message.faileddocs.pages.webhooks.events.list.message.failedYes
conversation.createddocs.pages.webhooks.events.list.conversation.createdYes
conversation.assigneddocs.pages.webhooks.events.list.conversation.assignedYes
conversation.updateddocs.pages.webhooks.events.list.conversation.updatedYes
conversation.closeddocs.pages.webhooks.events.list.conversation.closedYes
conversation.reopeneddocs.pages.webhooks.events.list.conversation.reopenedYes
conversation.note_addeddocs.pages.webhooks.events.list.conversation.note_addedYes
channel.createddocs.pages.webhooks.events.list.channel.createdYes
channel.connecteddocs.pages.webhooks.events.list.channel.connectedYes
channel.disconnecteddocs.pages.webhooks.events.list.channel.disconnectedYes
channel.requires_repairdocs.pages.webhooks.events.list.channel.requires_repairYes
channel.archiveddocs.pages.webhooks.events.list.channel.archivedYes
contact.createddocs.pages.webhooks.events.list.contact.createdYes
contact.updateddocs.pages.webhooks.events.list.contact.updatedYes
contact.deleteddocs.pages.webhooks.events.list.contact.deletedYes
contact.import_completeddocs.pages.webhooks.events.list.contact.import_completedYes
template.createddocs.pages.webhooks.events.list.template.createdYes
template.updateddocs.pages.webhooks.events.list.template.updatedYes
template.archiveddocs.pages.webhooks.events.list.template.archivedYes
campaign.starteddocs.pages.webhooks.events.list.campaign.startedNot yet
campaign.pauseddocs.pages.webhooks.events.list.campaign.pausedNot yet
campaign.resumeddocs.pages.webhooks.events.list.campaign.resumedNot yet
campaign.completeddocs.pages.webhooks.events.list.campaign.completedNot yet
campaign.cancelleddocs.pages.webhooks.events.list.campaign.cancelledNot yet
campaign.faileddocs.pages.webhooks.events.list.campaign.failedNot yet
automation.faileddocs.pages.webhooks.events.list.automation.failedNot 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.

Delay before the next attempt, at the default configuration.
After attemptNext attempt in
15–10s
210–20s
320–40s
440–80s
580–160s

What counts as a failure

ResponseRetriedfailure_reason
2xxNook
3xxNoredirect
429Yesrate_limited
4xxNoclient_error
5xxYesserver_error
A connection, DNS or TLS errorYesnetwork_error
No response within the timeoutYestimeout

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

GET/v1/webhook-endpoints/{endpointId}/deliveriesGET/v1/webhook-deliveriesGET/v1/webhook-deliveries/{deliveryId}POST/v1/webhook-deliveries/{deliveryId}/replay

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.

curl
# 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 Accepted

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