# Webhooks

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

Canonical: https://docs.whabuzz.com/guides/webhooks/

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](https://docs.whabuzz.com/events.md) shows every payload.

## Delivery guarantees {#guarantees}

- **At least once.** Treat every event as possibly repeated. Dedupe on `dedupe_key`.
- **Not ordered.** A `read` status can arrive before `delivered`. Compare timestamps.
- **Retried for 24 hours** on failure (see [Retries](#retries)), then marked `dead`. Dead deliveries can be [replayed](#replay) within your retention window.
- **Full fidelity.** The `raw` field holds Meta's exact original POST body.

## Endpoints {#endpoints}

[`POST /v1/webhooks`](https://docs.whabuzz.com/api/webhooks.md#createWebhookEndpoint) creates one. Up to 10 per tenant.

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

- `url` must be `https`, a hostname (not an IP address), and not under `whabuzz.com`.
- `event_types` is `["*"]` for everything, or an exact list such as `["message.inbound","message.status"]`.
- `status` is `pending_verification`, then `active` after the [verify ping](#verify-ping), or `disabled` (paused by you).
- The create response includes the **signing secret**, `whsec_` plus 32 random bytes, base64url. It is shown once. Rotate with [`rotate-secret`](https://docs.whabuzz.com/api/webhooks.md#rotateWebhookSecret) (both secrets sign for an overlap of `overlap_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 with `overlap_hours: 0`.

Manage with [list](https://docs.whabuzz.com/api/webhooks.md#listWebhookEndpoints), [get](https://docs.whabuzz.com/api/webhooks.md#getWebhookEndpoint), [update](https://docs.whabuzz.com/api/webhooks.md#updateWebhookEndpoint) and [delete](https://docs.whabuzz.com/api/webhooks.md#deleteWebhookEndpoint).

## Verify ping {#verify-ping}

Right after creation (and whenever you call [`POST /v1/webhooks/{endpoint_id}/verify`](https://docs.whabuzz.com/api/webhooks.md#verifyWebhookEndpoint)) WhaBuzz POSTs:

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

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 {#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 {#verify-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);
  });
}
```

### Python {#verify-python}

```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")
```

### PHP {#verify-php}

```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;
}
```

## Request headers {#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 {#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`](https://docs.whabuzz.com/api/webhooks.md#listDeliveries) and [`GET /v1/webhook-deliveries/{delivery_id}`](https://docs.whabuzz.com/api/webhooks.md#getDelivery).

## Replay {#replay}

- One delivery: [`POST /v1/webhook-deliveries/{delivery_id}/replay`](https://docs.whabuzz.com/api/webhooks.md#replayDelivery) makes one synchronous attempt and returns its result. It creates a new delivery with `is_replay: true`.
- Many: [`POST /v1/webhooks/{endpoint_id}/replay`](https://docs.whabuzz.com/api/webhooks.md#bulkReplay) with a filter, answers `202 {"queued": n}`, capped at 10,000.

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

Replay works while the event's payload is inside your [retention](https://docs.whabuzz.com/guides/retention.md) window. Beyond it the call answers `404 not_found` with `details.reason: "payload_expired"`.

## Deduplicate {#dedupe}

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.
