WhatsApp API Webhooks: Setup, Payloads and Retries
Everything interesting on WhatsApp happens when your code isn't looking. A customer replies at 2 am, a message you sent an hour ago finally lands, Meta pauses a template, your number's quality rating drops. You find out about all of it the same way: an HTTP request from Meta to a URL you control.
WhatsApp API webhooks are that channel. Get them right and your integration knows the state of every conversation within seconds. Get them wrong and you miss replies, double-process events, or watch Meta retry the same payload for a week.
This guide covers the lifecycle (verify, subscribe, receive), which fields are worth subscribing to, a server that passes verification and stays fast, Meta's delivery rules, and how Tenashi's webhooks differ. For the bigger picture, see our complete guide to the WhatsApp API.
How the webhook lifecycle works
A Cloud API webhook goes through four stages, and each one has its own way to fail quietly.
- You build an endpoint on a public HTTPS server that accepts both
GETandPOST. It needs a valid TLS certificate; Meta doesn't accept self-signed ones. - Meta verifies it. When you enter a Callback URL and Verify token in App Dashboard, WhatsApp, Configuration, Meta sends a
GETwithhub.mode=subscribe, a randomhub.challengeand yourhub.verify_token. If the token matches, you reply200with the challenge value as the body. Anything else and Meta treats the endpoint as unverified and sends nothing. - You subscribe to fields. Once verification passes, the dashboard lists the fields you can subscribe to. Pick only what you'll handle.
- Your app is subscribed to each WhatsApp Business Account. For accounts onboarded through the API or Embedded Signup, call
POST /WABA_ID/subscribed_appswith a token for that account. Skipping this step is a classic reason a verified endpoint stays silent.
After that, every event on a subscribed field arrives as a POST with a JSON body and an X-Hub-Signature-256 header. The envelope is always the same: object is whatsapp_business_account, entry is an array whose id is the account ID, and each entry holds changes, each with a field name and a value payload.
Which webhook fields to subscribe to
Meta lists around twenty fields. Most integrations need three or four. These are the ones that matter for messaging, as listed in Meta's webhooks overview:
| Field | Fires when | Who needs it |
|---|---|---|
messages | A user messages you, or a message you sent changes status | Everyone |
message_template_status_update | A template is approved, rejected, paused or disabled | Anyone sending templates |
template_category_update | Meta changes a template's category | Anyone watching cost, since category sets price |
message_template_quality_update | A template's quality score changes | High-volume senders |
user_preferences | A user stops or resumes marketing messages | Marketing senders, to honour opt-outs |
account_alerts | Messaging limit, business profile or Official Business Account status changes | Ops and on-call |
account_update | Verification, policy violations, offboarding and similar account events | Ops and compliance |
phone_number_quality_update | A number's throughput level changes | High-volume senders |
smb_message_echoes | A coexistence number sends from the WhatsApp Business app | Anyone running the app and API on one number |
Permissions decide what you can receive: messages (and calls) need whatsapp_business_messaging, the rest need whatsapp_business_management. Inbound messages and status updates share the messages field; the payload has a messages array for the former and a statuses array for the latter. Our guides to receiving messages and message status webhooks take each one apart.
A webhook server that passes verification and stays fast
This Express server answers the verification GET, checks the signature on the raw bytes of every POST, acknowledges immediately, and hands the payload to a queue. It runs as written on Node 18 or later.
import express from "express";
import crypto from "node:crypto";
const app = express();
const { VERIFY_TOKEN, APP_SECRET } = process.env;
const queue = []; // stand-in for SQS, Redis, a Postgres table, etc.
// 1. Verification handshake: Meta sends a GET when you save the Callback URL.
app.get("/webhooks/whatsapp", (req, res) => {
const mode = req.query["hub.mode"];
const token = req.query["hub.verify_token"];
const challenge = req.query["hub.challenge"];
if (mode === "subscribe" && token === VERIFY_TOKEN) {
return res.status(200).type("text/plain").send(challenge);
}
return res.sendStatus(403);
});
// 2. Event notifications: keep the raw bytes so the signature can be checked.
app.post(
"/webhooks/whatsapp",
express.raw({ type: "application/json", limit: "5mb" }), // payloads can reach 3 MB
(req, res) => {
const header = req.get("x-hub-signature-256") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", APP_SECRET).update(req.body).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge first, work later
queue.push(JSON.parse(req.body.toString("utf8")));
}
);
app.listen(3000);
Three details in that code are load-bearing:
- The challenge goes back as plain text. Return the exact string Meta sent. A JSON-encoded body such as
"1158201444", quotes included, is not the challenge value. express.rawinstead ofexpress.jsonon the webhook route, so the HMAC is computed over the exact bytes Meta signed. The signature verification guide explains why parsed-then-restringified JSON never matches, with Python versions too.- The
200goes out before any real work. A worker drains the queue, writes to your database and calls whatever else needs calling.
Delivery rules: batching, retries and duplicates
Meta's documentation sets out how events are delivered, and each rule should shape your handler:
- Batching. One
POSTcan aggregate up to 1,000 updates, but batching isn't guaranteed. Loop over everyentryand everychange. - Size. Payloads can be up to 3 MB. Express body parsers default to a 100 KB limit, which is why the server above raises it.
- Retries. Any response other than
200, or a failed delivery, is retried immediately and then with decreasing frequency for up to 7 days. After that, unacknowledged events are dropped. - Duplicates. Retries go to every app subscribed to the account's fields, and Meta tells you to de-duplicate. Use the message
wamidfor inbound messages and thewamidplus status for status updates. - No replay API. Meta provides no endpoint for fetching past webhook data, so persist payloads before processing them.
Speed is part of the contract too. Meta's throughput page asks webhook servers to keep median latency at or under 250 ms, with fewer than 1% of responses over one second, and to plan for roughly three status webhooks per outbound message plus one webhook per inbound message. A campaign pushing 50 messages a second with a 20% reply rate therefore means about 150 status webhooks and 10 inbound webhooks every second, arriving concurrently.
Routing, overrides and locking it down
By default all webhooks go to the Callback URL on your app. You can override that per WhatsApp Business Account (override_callback_uri on the subscribed_apps call) or per business phone number (a webhook_configuration object on the phone number). Meta checks the number first, then the account, then falls back to the app. That's handy for staging numbers or giving each customer of a SaaS its own endpoint.
Overrides only apply to some fields, including messages, smb_message_echoes and history. Template and account-level fields such as message_template_status_update, account_update and account_alerts always go to the app's default URL, so that endpoint must stay healthy even if all message traffic is routed elsewhere.
For security, rely on the signature first. Meta also supports mutual TLS for webhooks, with a client certificate whose common name you can check. You can allowlist Meta's IP ranges, but Meta changes them periodically and suggests mTLS instead. Our WhatsApp API security checklist covers tokens and secrets alongside webhooks.
Tenashi webhooks compared
Tenashi's developer API has its own webhooks for numbers you've linked to Tenashi. The concepts match; the details differ:
| Meta Cloud API | Tenashi | |
|---|---|---|
| Set up | Dashboard Callback URL plus verify-token GET handshake | Add in Settings or POST /api/v1/webhooks, up to 10 endpoints |
| Events | Fields such as messages, template and account updates | message.received, message.sent, message.delivered, message.read, message.failed, number.connected, number.disconnected |
| Signature | X-Hub-Signature-256, HMAC-SHA256 of the body with the app secret | X-Tenashi-Signature: v1=, HMAC-SHA256 of timestamp, a dot and the raw body, with a whsec_ secret |
| Replay protection | No timestamp in the signature | Reject timestamps older than 5 minutes |
| Response window | 200, fast (median 250 ms target) | Any 2xx within 10 seconds |
| Retries | Decreasing frequency for up to 7 days | 8 more tries over about a day (30 s up to 12 h) |
| Stopping | Unsubscribe the field or app | Return 410 Gone; 3 days of failures disables the endpoint |
| Debugging | Dashboard test payloads | Last 100 deliveries per endpoint with Resend (7 days) and Send test |
Both are at least once and not always in order, so the same handler discipline applies: verify, store, acknowledge, then process. Tenashi events carry an id to de-duplicate on, and message.sent fires for every message leaving your number, including ones typed on the phone, which is useful for syncing a CRM. Zapier, Make and n8n webhook URLs work as Tenashi endpoints without changes.
Where Tenashi fits
Building a Cloud API webhook pipeline is worth it when you're running templates and automations at volume. If what you need is to know when customers message your team, and to push those conversations into your own tools, Tenashi gives you signed webhooks on the number you already use, linked by QR scan, including messages from WhatsApp groups. The same conversations sit in a shared team inbox, so nobody has to build a UI on top. Plans start from $18/mo, API included.
The honest limit: Tenashi isn't Meta's official platform, so there are no template status or account events, and it isn't built for thousands of messages per minute.
Read the API docs · Get started
FAQ
What should my webhook endpoint return to Meta?
For the verification GET, return HTTP 200 with the hub.challenge value as a plain-text body, only when hub.verify_token matches yours. For event POSTs, return 200 as soon as you've checked the signature and stored the payload. Anything other than 200 counts as a failure, and Meta retries the delivery.
How long does Meta retry failed WhatsApp webhooks?
Meta retries immediately, then several more times with decreasing frequency for up to 7 days, after which unacknowledged events are dropped. Retries can produce duplicates across every subscribed app, so de-duplicate on message IDs. Because there's no API to fetch past webhook data, an endpoint that's down for over a week loses events permanently.
Can different phone numbers send webhooks to different URLs?
Yes. Meta supports overriding the callback URL per WhatsApp Business Account through the subscribed_apps endpoint, or per business phone number through its webhook configuration. Meta checks the number first, then the account, then your app's default. Template and account-level fields ignore overrides and always go to the app's URL.
Should I allowlist Meta's IP addresses for webhooks?
You can, using the AS32934 route list or Meta's geofeed, but Meta changes its addresses periodically, so an allowlist can silently start blocking real webhooks. Meta recommends mutual TLS as the sturdier option. Whichever you choose, always verify the X-Hub-Signature-256 header, which proves the payload came from Meta.
Why do I receive the same WhatsApp webhook twice?
Meta delivers webhooks at least once. If your endpoint was slow, returned an error, or the connection dropped, Meta retries, and retries go to every subscribed app. Store the message wamid, or the wamid and status for status updates, with a unique constraint, and skip anything you've already processed.
Webhooks are where a WhatsApp integration earns its reliability. If you want signed events for the conversations your team already has, groups included, read Tenashi's API docs.