WhatsApp webhooks that never lose an event

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, 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:

{
  "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": [ ] }
}
json

The full field list is in the event catalogue.

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.

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

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.

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.

The pattern that holds up:

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();
});
js

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:

AttemptDelay after the previous one
230 seconds
31 minute
42 minutes
55 minutes
610 minutes
720 minutes
840 minutes
91 hour
10 onwardshourly, 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 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:

Eventdedupe_key
message.inboundmsg:{wamid}
message.statusst:{wamid}:{status}:{timestamp}
message.errorerr:{sha256 of the error object}
message.outboundout:{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.

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
sql

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 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:

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

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