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
- At least once. Treat every event as possibly repeated. Dedupe on
dedupe_key. - Not ordered. A
readstatus can arrive beforedelivered. Compare timestamps. - Retried for 24 hours on failure (see Retries), then marked
dead. Dead deliveries can be replayed within your retention window. - Full fidelity. The
rawfield holds Meta's exact original POST body.
#Endpoints
POST /v1/webhooks creates one. Up to 10 per tenant.
{ "url": "https://api.example.com/whatsapp/events", "event_types": ["*"], "description": "prod" }jsonurlmust behttps, a hostname (not an IP address), and not underwhabuzz.com.event_typesis["*"]for everything, or an exact list such as["message.inbound","message.status"].statusispending_verification, thenactiveafter the verify ping, ordisabled(paused by you).- The create response includes the signing secret,
whsec_plus 32 random bytes, base64url. It is shown once. Rotate withrotate-secret(both secrets sign for an overlap ofoverlap_hours, 0 to 168, default 24). Signing keys are cached for speed, so for up to about 90 seconds after a rotation some deliveries still carry only the old signature: keep accepting the old secret for at least that long, even withoverlap_hours: 0.
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"
}jsonAnswer 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…e1v1 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
| Header | Value |
|---|---|
X-WhaBuzz-Signature | t=<unix seconds>,v1=<hex>[,v1=<hex>] |
X-WhaBuzz-Event-Id | evt_…, same as the body id |
X-WhaBuzz-Event-Type | for example message.inbound |
X-WhaBuzz-Delivery-Id | dlv_…, one per attempt chain |
X-WhaBuzz-Attempt | 1, 2, 3 … |
X-WhaBuzz-Received-At | when WhaBuzz received the event from Meta |
X-WhaBuzz-Processing-Ms | time WhaBuzz spent before forwarding |
User-Agent | WhaBuzz-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
- One delivery:
POST /v1/webhook-deliveries/{delivery_id}/replaymakes one synchronous attempt and returns its result. It creates a new delivery withis_replay: true. - Many:
POST /v1/webhooks/{endpoint_id}/replaywith a filter, answers202 {"queued": n}, capped at 10,000.
{ "from": "2026-10-16T00:00:00Z", "to": "2026-10-16T06:00:00Z", "status": ["dead","failed"], "event_types": ["message.inbound"] }jsonReplay 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:
| Event | dedupe_key |
|---|---|
message.inbound | msg:{wamid} |
message.status | st:{wamid}:{status}:{timestamp} |
message.error | err:{sha256 of the error object} |
| Other Meta fields | {field}:{sha256 of the canonical value} |
message.outbound | out:{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.