WhaBuzz docs
Guides

Webhooks

Endpoints, signed deliveries, the verify ping, retries, replay and deduplication.

Everything Meta tells WhaBuzz about your numbers, plus a few events WhaBuzz raises itself, is delivered to your webhook endpoints as signed HTTP POSTs. One event per request. The event catalogue shows every payload.

#Delivery guarantees

#Endpoints

POST /v1/webhooks creates one. Up to 10 per tenant.

{ "url": "https://api.example.com/whatsapp/events", "event_types": ["*"], "description": "prod" }
json

Manage with list, get, update and delete.

#Verify ping

Right after creation (and whenever you call POST /v1/webhooks/{endpoint_id}/verify) WhaBuzz POSTs:

{
  "type": "webhook.verify",
  "challenge": "6f1c0b8e4a7d4f29b3e5a0c2d9f81a47",
  "endpoint_id": "3c2b1a09-f8e7-4d6c-b5a4-9382716a5b4c",
  "created_at": "2026-10-16T09:30:00.000Z"
}
json

Answer 2xx within 5 seconds with the challenge echoed, either as the plain-text body or as JSON {"challenge":"…"}. Anything else leaves the endpoint in pending_verification and the API call answers 422 webhook_verify_failed with details.http_status and a body_excerpt of what your server returned. The endpoint is still created, so the same response includes its signing secret in details.secret; fix your handler and call verify again. The ping is signed like every delivery.

#Signature

Each request carries:

X-WhaBuzz-Signature: t=1760591523,v1=5b0c…e1

v1 is the lowercase hex HMAC-SHA256, keyed with your signing secret, of the string {t}.{raw request body}. During a secret rotation the header carries two v1= values; accept the request if any matches. Reject if t is more than 300 seconds from your clock. Compute over the raw bytes, before any JSON parsing.

#Node.js

import crypto from 'node:crypto';

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split(/=(.*)/s).slice(0, 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return header.split(',').filter((p) => p.startsWith('v1=')).some((p) => {
    const got = Buffer.from(p.slice(3), 'hex');
    const want = Buffer.from(expected, 'hex');
    return got.length === want.length && crypto.timingSafeEqual(got, want);
  });
}
js

#Python

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = [p.split("=", 1) for p in header.split(",")]
    t = int(next(v for k, v in parts if k == "t"))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(v, expected) for k, v in parts if k == "v1")
python

#PHP

function verify(string $rawBody, string $header, string $secret): bool {
    $t = null; $sigs = [];
    foreach (explode(',', $header) as $p) {
        [$k, $v] = explode('=', $p, 2);
        if ($k === 't') $t = (int)$v;
        if ($k === 'v1') $sigs[] = $v;
    }
    if (!$t || abs(time() - $t) > 300) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $s) if (hash_equals($expected, $s)) return true;
    return false;
}
php

#Request headers

HeaderValue
X-WhaBuzz-Signaturet=<unix seconds>,v1=<hex>[,v1=<hex>]
X-WhaBuzz-Event-Idevt_…, same as the body id
X-WhaBuzz-Event-Typefor example message.inbound
X-WhaBuzz-Delivery-Iddlv_…, one per attempt chain
X-WhaBuzz-Attempt1, 2, 3 …
X-WhaBuzz-Received-Atwhen WhaBuzz received the event from Meta
X-WhaBuzz-Processing-Mstime WhaBuzz spent before forwarding
User-AgentWhaBuzz-Webhooks/1

#Retries

The first attempt is made immediately, inline, with a 3-second deadline. A delivery succeeds on any 2xx. Redirects are not followed. Retry attempts time out at 10 seconds. Anything else (non-2xx, timeout, connection error) is a failure and is retried at:

30 seconds, 1 minute, 2, 5, 10, 20, 40 minutes, 1 hour, then hourly, until 24 hours after the first attempt. After that the delivery is dead.

Retries are driven by a per-minute scheduler, so the practical minimum gap is the next minute tick. After 5 consecutive failures an endpoint is flagged and new events skip the immediate attempt and wait for the scheduler, which protects both sides during an outage. Return 2xx as soon as you have durably queued the event; do the work afterwards.

Inspect attempts with GET /v1/webhook-deliveries and GET /v1/webhook-deliveries/{delivery_id}.

#Replay

{ "from": "2026-10-16T00:00:00Z", "to": "2026-10-16T06:00:00Z", "status": ["dead","failed"], "event_types": ["message.inbound"] }
json

Replay works while the event's payload is inside your retention window. Beyond it the call answers 404 not_found with details.reason: "payload_expired".

#Deduplicate

Every event has a dedupe_key that is identical across retries and replays of the same fact:

Eventdedupe_key
message.inboundmsg:{wamid}
message.statusst:{wamid}:{status}:{timestamp}
message.errorerr:{sha256 of the error object}
Other Meta fields{field}:{sha256 of the canonical value}
message.outboundout:{event_id}

Keep keys you have processed for at least 24 hours (the retry horizon), longer if you replay.

This page as Markdown: /guides/webhooks.md. All docs in one file: /llms-full.txt.