WhatsApp webhook handling: signatures, retries, duplicates
How to handle WhatsApp webhooks for an AI agent: verify the Standard Webhooks signature, answer within 10 seconds, survive retries and dedupe events. TypeScript.

Key takeaways
- A whasp webhook is signed with Standard Webhooks, delivered at least once and not guaranteed to arrive in order, so a receiver must verify, dedupe and tolerate reordering.
- Verify the signature against the raw request body, never against re-serialized JSON, and reject timestamps more than five minutes off.
- Write the event to durable storage, then answer 2xx within 10 seconds and run the slow work afterwards: whasp keeps an event's body only until it is delivered or given up.
- If your endpoint keeps failing, whasp retries 12 times over roughly eight hours and then gives up, and the content of a failed message.received cannot be sent again.
- Dedupe on the event id and send every reply with an Idempotency-Key built from the inbound message id.
A whasp webhook is an HTTPS POST per event, signed with Standard Webhooks, delivered at least once and in no guaranteed order. A reliable receiver does four things: it verifies the signature against the raw body, writes the event to durable storage, answers 2xx within 10 seconds, and does the slow work afterwards, deduplicating on the event id. This guide builds that receiver in TypeScript.
What does a webhook delivery contain?
A whasp delivery is one POST with a JSON envelope and three signature headers. The envelope holds the event id, its type, a created_at time and the event’s data. The headers are webhook-id (the event id, the same on every retry), webhook-timestamp (unix seconds) and webhook-signature (v1, followed by a base64 HMAC-SHA256).
Eight event types exist:
| Type | When it fires | What a receiver does |
|---|---|---|
message.received |
A person wrote to a number. Carries text, media and more. |
Store it and run the agent. This is the only copy of the content. |
message.sent |
An outbound message left the phone. | Update your own record. |
message.failed |
An outbound message failed; the reason is in message.error. |
Log it. Retry only if the reason is temporary. |
reaction.received |
Someone reacted to a message; an empty emoji means removed. |
Usually ignore it. |
number.ready |
A number that was provisioning is now active. |
Start routing users to it. |
number.status_changed |
health or online changed. |
Alert on critical. |
number.replaced |
A banned number was replaced. Both numbers are in the event. | Move your users to the new number. |
webhook.test |
You called POST /v1/webhook/test. |
Answer 2xx. |
How do you verify the signature?
Signature verification proves that a request came from whasp and that nobody changed its body. The standardwebhooks library does it in one call, and the project publishes libraries for JavaScript, Python, Go, Java, Ruby, Rust and .NET. This is the algorithm from the Standard Webhooks spec written out with node:crypto, for stacks without a library (in Hono, pass c.req.raw.headers and the text body):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, headers: Headers, rawBody: string): boolean {
const id = headers.get("webhook-id") ?? "";
const timestamp = headers.get("webhook-timestamp") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= 300)) return false; // older or newer than 5 minutes; also catches NaN
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// The header may hold several space-separated signatures, e.g. "v1,abc= v1,def=".
return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) return false;
const got = Buffer.from(signature, "base64");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Five mistakes commonly break verification:
- Parsed JSON. The signature covers the exact bytes whasp sent. Read the body as text first, verify, and only then parse. A re-serialized object can differ in whitespace or key order.
- The wrong key. A secret looks like
whsec_plus base64. The key is the decoded bytes, not the string. Both libraries strip the prefix and decode for you. - Clock drift. Timestamps more than five minutes from your clock are rejected. Keep the server on NTP.
- A plain
===comparison. UsetimingSafeEqual, which also throws on different lengths, hence the length check. - Only reading the first signature. The header can hold several space-separated signatures. Accept the request if any
v1one matches.
Answer 401 when verification fails. whasp treats it like any failure and retries, so a wrong secret does not lose events at once, but it does after the retries run out. POST /v1/webhook/rotate-secret issues a new secret. The API does not say whether whasp signs with both secrets during a rotation, so update your configuration right away; anything rejected in between is retried 30 seconds later.
Why answer first and work later?
A receiver should store the event and answer first, because whasp waits 10 seconds for a 2xx and an agent’s work, a model call or a tool call, often takes longer. Answering late creates a retry, and a retry creates a duplicate.
Storing comes first because whasp keeps an event’s body only until it is delivered or given up, and whasp’s servers keep no messages. Once your endpoint returns 2xx, you own the only copy of a message.received. The pattern is a durable inbox: insert the raw event, answer 204, and let a worker process rows from the table. The full receiver is below, using Hono and node:sqlite. Install hono, @hono/node-server and standardwebhooks in a project with "type": "module", set WHASP_WEBHOOK_SECRET, and run it with node --env-file=.env webhook.ts on Node 22.18 or newer.
import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Webhook } from "standardwebhooks";
type WhaspEvent = { id: string; type: string; created_at: string; data: any };
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...
const MAX_ATTEMPTS = 6;
const db = new DatabaseSync("events.db");
db.exec(`
CREATE TABLE IF NOT EXISTS inbox (
id TEXT PRIMARY KEY, type TEXT NOT NULL, chat TEXT, body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | dead
attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT,
received_at INTEGER NOT NULL DEFAULT (unixepoch()));
`);
const app = new Hono();
app.post("/whasp/webhook", async (c) => {
const body = await c.req.text(); // the raw bytes that were signed
let event: WhaspEvent;
try {
event = wh.verify(body, c.req.header()) as WhaspEvent;
} catch {
return c.text("Invalid signature", 401);
}
const chat = event.data.message?.chat ?? event.data.chat ?? null;
// Durable before 2xx. A repeated id inserts nothing, and still gets a 2xx.
db.prepare("INSERT OR IGNORE INTO inbox (id, type, chat, body) VALUES (?, ?, ?, ?)").run(
event.id, event.type, chat, body,
);
void drain(); // start now; the timer below only picks up retries
return c.body(null, 204);
});
// The worker: one event at a time per chat, many chats in parallel.
const busy = new Set<string>();
async function drain() {
const due = db
.prepare("SELECT id, chat, body, attempts FROM inbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid")
.all(Date.now()) as { id: string; chat: string | null; body: string; attempts: number }[];
for (const row of due) {
const lane = row.chat ?? row.id;
if (busy.has(lane)) continue;
busy.add(lane);
void work(row).finally(() => busy.delete(lane));
}
}
async function work(row: { id: string; body: string; attempts: number }) {
try {
await handle(JSON.parse(row.body));
db.prepare("UPDATE inbox SET status = 'done' WHERE id = ?").run(row.id);
} catch (err) {
const attempts = row.attempts + 1;
const status = attempts >= MAX_ATTEMPTS ? "dead" : "pending";
const next = Date.now() + Math.min(2 ** attempts * 5_000, 300_000); // 10 s, 20 s, 40 s...
db.prepare("UPDATE inbox SET status = ?, attempts = ?, next_at = ?, error = ? WHERE id = ?").run(
status, attempts, next, String(err), row.id,
);
}
}
async function handle(event: WhaspEvent) {
switch (event.type) {
case "message.received":
console.log("inbound from", event.data.message.chat, event.data.message.text);
return; // call your agent here, then POST /v1/messages
case "message.failed":
console.warn("send failed", event.data.message.id, event.data.message.error);
return;
case "number.status_changed":
if (event.data.number.health === "critical") console.error("number critical", event.data.number.number);
return;
case "number.replaced":
console.warn("replaced", event.data.old_number, "->", event.data.new_number.number);
return;
default:
return; // message.sent, reaction.received, number.ready, webhook.test
}
}
setInterval(drain, 1000);
serve({ fetch: app.fetch, port: 3000 });
Three parts of it carry the weight. The INSERT OR IGNORE on the event id is the dedupe and the durability in one statement. The drain loop runs one event at a time per chat and many chats in parallel, so a slow conversation never blocks the others. And work records each failure with an attempt count and a growing delay, then parks the row as dead after six tries, so a poison event stops looping and stays in the table for you to read.
Prune finished rows daily with DELETE FROM inbox WHERE status = 'done' AND received_at < unixepoch() - 86400. Keep ids longer than whasp’s retry window, or a late retry looks new. After an outage at your model provider, requeue the parked rows with UPDATE inbox SET status = 'pending', attempts = 0 WHERE status = 'dead'.
What happens when your endpoint fails?
When your endpoint fails, whasp retries the same event with growing gaps. A failure is any non-2xx answer, a connection error, or no answer within 10 seconds. A 401 from a bad signature counts too.
| Retry | Wait after the previous failure |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 to 12 | 1 hour each |
The twelve retries span roughly eight hours. After the last one the delivery is marked failed, and since whasp drops the body of an event once it is delivered or given up, the text of a message.received that failed every time cannot be sent again. Without a webhook URL, events are only listed as no_endpoint and never sent.
Two habits follow. Keep the endpoint independent of your model provider: with the inbox, an outage there shows up as rows in your table, not as failed deliveries. And watch GET /v1/webhook/deliveries, which lists the last 7 days with each delivery’s status, attempts, last_status_code and last_error.
How do you handle duplicates and ordering?
Duplicate events are normal, because delivery is at least once. The same event arrives twice when your 2xx is lost on the network or arrives after 10 seconds. Dedupe on the event id (the same value as webhook-id), keep answering 2xx for repeats, and your handler runs once per event.
Order is not guaranteed either. A message that needed a retry can land after the one the person sent next. The receiver above already runs one chat at a time. Sort the history you build for the model by message.created_at instead of by arrival, and expect bursts: three messages in ten seconds produce three events, so consider waiting two or three seconds for more before replying once.
Duplicates also reach what you send back. Send every reply with an Idempotency-Key built from the inbound message id, for example reply-msg_..., not a random value per attempt. whasp keeps a key for 7 days per account: the same key returns the first message and never sends twice. A refused send (4xx) does not use up the key, and a 409 idempotency_conflict means the first request with that key is still running, so retry after a moment. The same care applies to tools with side effects, such as bookings or refunds: a replayed event re-runs your agent, so guard them with the inbound message id, as the Claude tutorial explains.
How do you test and debug webhooks?
You test a whasp webhook in four steps, and the last one needs no whasp account.
- Run the receiver and expose it.
cloudflared tunnel --url http://localhost:3000gives a temporary HTTPS address and needs no Cloudflare account. - Register it with
PUT /v1/webhookand a body of{ "url": "https://.../whasp/webhook" }. The response holds the secret. - Call
POST /v1/webhook/test. It sends awebhook.testright away and answers{ "delivered", "status_code", "error" }, so you see what your server returned. - Post a signed fake event to localhost.
Webhook.signproduces the header value, so this script needs no whasp account and no tunnel:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!);
const event = {
id: `evt_local_${Date.now()}`,
type: "message.received",
created_at: new Date().toISOString(),
data: { message: { id: "msg_local_1", chat: "+15555550111", text: "Hello from a test", media: null } },
};
const body = JSON.stringify(event);
const res = await fetch("http://localhost:3000/whasp/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
"webhook-id": event.id,
"webhook-timestamp": String(Math.floor(Date.now() / 1000)),
"webhook-signature": wh.sign(event.id, new Date(), body),
},
body,
});
console.log(res.status); // 204
Run it with node --env-file=.env send-test-event.ts while the receiver is up. Change type and data to rehearse message.failed or number.replaced the same way.
What should you build next?
Wire the receiver to an agent: the OpenAI tutorial and the Claude tutorial both start from this pattern. Give the agent a memory, or build the same flow without code in n8n. When one number is not enough, plan capacity for several.
Questions
How do I verify a WhatsApp webhook signature from whasp?
Recompute an HMAC-SHA256 over the string webhook-id, a dot, webhook-timestamp, a dot and the raw body, keyed with the base64-decoded part of your whsec_ secret, and compare it in constant time with the v1 value in webhook-signature. The standardwebhooks library for your language does it in one call.
How many times does whasp retry a failed webhook?
Up to 12 times, after 30 seconds, 2 minutes, 10 minutes, 30 minutes and then hourly. After the last failure the delivery is marked failed and the event body is dropped.
Can the same webhook event arrive twice?
Yes. Delivery is at least once, so a lost 2xx or a slow answer produces a duplicate with the same event id. Store the id with a unique constraint and answer 2xx for repeats.
What should a webhook endpoint return?
Any 2xx status within 10 seconds counts as delivered, and 204 with no body is enough. A 4xx, a 5xx, a timeout or a connection error is a failure and is retried.
How do I test a whasp webhook locally?
Expose your port with a tunnel, register the URL with PUT /v1/webhook, and call POST /v1/webhook/test. You can also sign a fake event yourself with the standardwebhooks library and post it to localhost.


