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.
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);
}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
| Result | Handling |
|---|---|
| 200 server_ack | Keep the real message id and match later message.status events. |
| 504 send_unconfirmed | Record possibly delivered. Do not resend automatically. |
| 503 lane_reclaimed, ambiguous: false | Refused before sending. Retry after Retry-After. |
| 503 lane_reclaimed, ambiguous: true | A write may have applied. Inspect before retrying. |
| Lost response or other 500, 502, 503 write error | Do 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 sandboxKeep going
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.