# WhatsApp webhooks that never lose an event

> How to receive WhatsApp Cloud API webhooks without losing or double-processing events: signatures, retries, dedupe_key, seq and replay.

Source: https://whabuzz.com/blog/whatsapp-webhook-reliability/
By Smaran Challapalli. Published 16 Oct 2026. Updated 16 Oct 2026.

A reliable WhatsApp webhook handler verifies the signature, stores the event, answers 2xx within a few seconds, and deduplicates on a stable key, because every delivery can arrive twice, late or out of order. This post walks through each step with the request shapes WhaBuzz actually sends, and what to do when your endpoint is down for an hour.

## What can go wrong with WhatsApp webhooks?

Three things break most handlers in production.

1. **Duplicates.** Delivery is at least once. A retry after a timeout means your code sees the same message twice. If that message triggers an order confirmation, the customer gets two.
2. **Ordering.** A `read` status can arrive before `delivered`. Code that assumes order will mark a message as delivered after it was read.
3. **Downtime.** A deploy, a crashed worker or an expired TLS certificate. Without retries, every event sent during that window is gone.

None of these are rare. A single slow database query is enough to push a handler past a 3 second deadline and trigger a retry.

## How does WhaBuzz deliver an event?

WhaBuzz stores each event first, then acknowledges Meta, then delivers to your endpoints. A slow endpoint never delays Meta, and a delivery failure never loses the event. Per the [reliability page](https://docs.whabuzz.com/reliability/), Meta's webhook gets a `200` only after the event is stored.

Every delivery is a signed `POST` with one event in a common envelope. The `data` field holds Meta's own object verbatim and `raw` holds the complete Meta POST:

```json
{
  "id": "evt_01JAB3K9X2S8C4M7Q1V5R6T0YZ",
  "type": "message.inbound",
  "api_version": "2026-10-16",
  "created_at": "2026-10-16T05:12:03.481Z",
  "phone_number_id": "1013622581841046",
  "wamid": "wamid.HBgLMTY1MDU1NTEyMzQ…",
  "wa_id": "16505551234",
  "dedupe_key": "msg:wamid.HBgLMTY1MDU1NTEyMzQ…",
  "data": { "messaging_product": "whatsapp", "metadata": { }, "contacts": [ ], "message": { } },
  "raw": { "object": "whatsapp_business_account", "entry": [ ] }
}
```

The full field list is in the [event catalogue](https://docs.whabuzz.com/events/).

## How do you verify the signature?

Each request carries an `X-WhaBuzz-Signature` header in the form `t=1760591523,v1=5b0c…e1`. The `v1` value is a lowercase hex HMAC-SHA256 of `{t}.{raw request body}`, keyed with your endpoint's signing secret. Compute it over the raw bytes before any JSON parsing, and reject anything where `t` is more than 300 seconds from your clock.

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

During a secret rotation the header carries two `v1=` values, and the request is valid if any of them matches. Signing keys are cached, so keep accepting the old secret for about 90 seconds after a rotation even with no overlap set. Python and PHP versions of this function are in the [webhooks guide](https://docs.whabuzz.com/guides/webhooks/#signature).

## Why should the handler answer before doing the work?

The first attempt has a 3 second deadline. Retries time out at 10 seconds. If your handler sends an email, calls a CRM and writes three tables before it answers, it will miss the deadline on a busy day, and the retry lands while the first run is still going.

> Return `2xx` as soon as you have durably queued the event; do the work afterwards.
>
> Source: [WhaBuzz webhooks guide](https://docs.whabuzz.com/guides/webhooks/#retries)

The pattern that holds up:

```js
app.post('/whatsapp/events', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verify(req.body.toString('utf8'), req.get('X-WhaBuzz-Signature'), process.env.WHSEC)) {
    return res.status(400).end();
  }
  const event = JSON.parse(req.body);
  await queue.add(event.dedupe_key, event); // durable: a table, a queue, anything that survives a restart
  res.status(204).end();
});
```

The worker that drains the queue does the slow work at its own pace.

## What happens when your endpoint is down?

A delivery succeeds on any `2xx`. Redirects are not followed. Anything else, including a timeout or a connection error, is a failure and is retried on a fixed ladder:

| Attempt | Delay after the previous one |
|---|---|
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 5 minutes |
| 6 | 10 minutes |
| 7 | 20 minutes |
| 8 | 40 minutes |
| 9 | 1 hour |
| 10 onwards | hourly, until 24 hours after the first attempt |

Retries run on a per-minute scheduler, so the real gap is the next minute tick. In our staging tests, a test sink in a failing mode receives attempt 4 at about 3.5 minutes, as the [reliability page](https://docs.whabuzz.com/reliability/) records.

After 5 consecutive failures the endpoint's circuit opens: new events skip the immediate attempt and wait for the scheduler, which protects both sides during an outage. After 24 hours the delivery is marked `dead` and WhaBuzz emails you. A one-hour outage therefore costs you nothing but latency. The events arrive once you are back.

You can inspect every attempt with `GET /v1/webhook-deliveries`, or in the console.

## How do you deduplicate with dedupe_key?

Every event carries a `dedupe_key` that stays identical across retries and replays of the same fact. Its shape depends on the event type:

| Event | `dedupe_key` |
|---|---|
| `message.inbound` | `msg:{wamid}` |
| `message.status` | `st:{wamid}:{status}:{timestamp}` |
| `message.error` | `err:{sha256 of the error object}` |
| `message.outbound` | `out:{event_id}` |

Store processed keys with a unique constraint and skip any insert that conflicts. Keep them for at least 24 hours, the retry horizon, and longer if you replay older events.

```sql
CREATE TABLE processed_events (dedupe_key text PRIMARY KEY, seen_at timestamptz NOT NULL DEFAULT now());
-- in the worker:
INSERT INTO processed_events (dedupe_key) VALUES ($1) ON CONFLICT DO NOTHING RETURNING dedupe_key;
-- no row returned: already handled, skip
```

For ordering, compare timestamps rather than arrival order. A `read` status that arrives first should not be overwritten by a later `delivered`.

## How do you spot a gap with seq?

Every envelope also has `seq`, the event's position in your tenant's own log, and `seq` only grows. If you consume the [push stream](https://docs.whabuzz.com/stream/) over WebSocket, frames arrive in `seq` order with no duplicates within one connection. Reconnect with `wss://api.whabuzz.com/v1/stream?since=<seq>` and you get everything after the last frame you processed, then live events on the same socket.

A gap in `seq` on your side means you missed something, and replay fills it.

## When should you replay events?

Replay is for the day your own code had a bug. You deploy a fix, then ask for the events again:

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

Send that body to `POST /v1/webhooks/{endpoint_id}/replay` and WhaBuzz answers `202 {"queued": n}`, up to 10,000 events per call. A replayed event arrives with the same `id`, `seq` and `dedupe_key` as the original, plus a new delivery id, so your dedupe table handles it without special code.

Replay works while the event's body is inside your [retention window](https://docs.whabuzz.com/guides/retention/): 30, 90, 180 or 365 days, default 90. Beyond that the call answers `404 not_found` with `details.reason: "payload_expired"`.

## Frequently asked questions

### Does WhaBuzz guarantee each webhook arrives exactly once?

No. Delivery is at least once. You may see an event twice, never zero times. Deduplicate on `dedupe_key`.

### How long does WhaBuzz retry a failed webhook?

For 24 hours after the first attempt, on the ladder +30 s, 1 m, 2 m, 5 m, 10 m, 20 m, 40 m, 1 h, then hourly. After that the delivery is marked `dead` and you get an email.

### Can I get events again after fixing a bug?

Yes. Replay one delivery or a filtered range of up to 10,000 events, as long as the events are inside your retention window of 30, 90, 180 or 365 days.

### Is the original Meta payload included?

Yes. The `raw` field holds Meta's exact original POST body, and `data` holds Meta's object for that event unchanged.

## Related reading

- [Idempotency keys](https://docs.whabuzz.com/guides/idempotency/) cover the other direction: sending without duplicates.
- The [webhooks guide](https://docs.whabuzz.com/guides/webhooks/) has every endpoint, header and limit.
- [All guides by task](https://whabuzz.com/guides/), and the [WhatsApp API overview](https://whabuzz.com/whatsapp-api/) for pricing and provider basics.
