Guides

WhatsApp webhook retries and outcome unknown: never auto-resend

HelloHop retries ordinary webhook deliveries from a durable outbox when your receiver does not accept them. Deduplicate using event.id inside the verified body, and persist acceptance before returning a success response. Sending a WhatsApp message is different: a timeout can mean outcome unknown, so never automatically resend a possibly delivered message.

Facts (as of October 2026, API v1)

  • Current behavior; see /docs/webhooks for the reference values.
  • Ordinary durable deliveries have an attempt ceiling of at least 50, including the initial attempt.
  • Backoff starts at 1 second and doubles to a 1-hour base cap, plus random jitter of less than 500 milliseconds.
  • A retry keeps the body event id. The signature covers body bytes, not the delivery header.
  • POST /webhooks/test makes one deliberate attempt. It does not use the ordinary retry budget.

A delivery retry is not another message send

A webhook is an HTTP event delivery to your server. Network errors and non-2xx responses make an ordinary delivery eligible for retry. Repeating that event must not repeat your business action. A message send is a separate write to WhatsApp with no API idempotency key.

What the outbox retains

Ordinary events are stored before delivery. A pending or delivering row blocks later rows for the same receiver URL and channel. Other URLs and channels can proceed. Recovery can repeat deliveries, so this is not an exactly-once or global ordering guarantee.

After the attempt ceiling, a delivery is marked failed for inspection rather than retried forever. Undelivered rows, including failed rows, expire 14 days after creation. There is no public failed-event list or replay endpoint.

Verify bytes before using the event id

Verify X-Wagate-Signature with the receiver secret before parsing JSON. X-Wagate-Delivery is informational and is not signed. Dedupe on the body event.id only after verification. An empty secret or a parsed object instead of a raw Buffer must be refused.

Node.js verifier: verify.js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifySignature(rawBody, signatureHeader, secret) {
  // A JSON body parser that ran first leaves a parsed object, not bytes: refuse it rather than throw.
  if (!Buffer.isBuffer(rawBody) || !secret || !signatureHeader) return false; // an empty key would accept a forgery signed with an empty key
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const given = Buffer.from(signatureHeader);
  const want = Buffer.from(expected);
  return given.length === want.length && timingSafeEqual(given, want);
}
Node.js receiver
import express from 'express';
import { verifySignature } from './verify.js';
import { enqueueDurably } from './queue.js'; // your durable queue or database write

const app = express();
const { WEBHOOK_SECRET: SECRET } = process.env;
if (!SECRET) throw new Error('Set WEBHOOK_SECRET before you start');
const seen = new Set(); // illustration only: production needs a durable dedupe store

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifySignature(req.body, req.get('x-wagate-signature'), SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // The delivery header is NOT signed. Dedupe on the id inside the signed body.
  if (seen.has(event.id)) return res.sendStatus(200); // a retry of an event you already accepted
  try {
    await enqueueDurably(event); // persist first; process from the queue later
  } catch {
    return res.sendStatus(500); // not accepted: non-2xx so the sender retries
  }
  seen.add(event.id); // mark seen only after the event is safely stored
  res.sendStatus(200);
});

app.listen(3000);

This receiver illustrates the acceptance order. Its in-memory Set is not production deduplication: replace it with a durable unique constraint on event.id and make the dedupe record and queued event one transaction. Make downstream work idempotent too. Return 2xx only after durable acceptance; return non-2xx when acceptance fails. A duplicate already accepted durably should get 200.

When a message send has an unknown outcome

ResultHandling
200 server_ackKeep the real message id and match later message.status events.
504 send_unconfirmedRecord possibly delivered. Do not resend automatically.
503 lane_reclaimed, ambiguous: falseRefused before sending. Retry after Retry-After.
503 lane_reclaimed, ambiguous: trueA write may have applied. Inspect before retrying.
Lost response or other 500, 502, 503 write errorDo not infer that nothing happened. Inspect before retrying.

An id returned with send_unconfirmed names a synthetic ambiguous log row, not a WhatsApp message ID. No message.status event will carry that synthetic id. Its presence or absence does not settle delivery. Confirm with the recipient where possible; if you resend without confirmation, accept a possible duplicate.

Questions

Which id should I deduplicate?
Use event.id from the signed body after verifying the signature. The delivery header repeats it but is not signed.
When should my receiver return 200?
After the event and dedupe record have been accepted durably, or for a duplicate already accepted durably. A failed durable write should receive non-2xx.
Are retries unlimited?
No. Ordinary deliveries have an attempt ceiling of at least 50, then become failed. Undelivered rows expire 14 days after creation. A webhook test is a single attempt.
Does webhook deduplication make message sends idempotent?
No. The send API has no idempotency key. A send_unconfirmed response or lost response can leave a message possibly delivered. Do not resend automatically.

Free sandbox, forever. Add a real number when you're ready.

Start in the free sandbox

HelloHop is an independent WhatsApp API for developers: send and receive messages, automate groups and communities, and get signed webhooks with a free sandbox. Not affiliated with WhatsApp or Meta. You link your own number.