Webhooks & events
Webhooks & events
Every WhatsApp message, delivery receipt and account-health change is pushed to your endpoint as a signed webhook — you never poll. This is the receive side of the WhatsApp API: the send side (send, templates, campaigns) tells WhatsApp what to do; webhooks tell you what happened.
Just want incoming messages on your server? Three steps: (1) create an org API key (
vai_live_…) in the dashboard; (2)POST /api/v1/hookswith that key and yourtarget_url— see Subscribe — Org REST-hooks below; (3) verify the signature on each delivery. The WhatsApp quickstart walks the whole flow end-to-end.
How inbound wiring works (you configure nothing)
There is no webhook URL to register with Meta and no callback token to manage. When your number is set up, Telenow calls Meta's subscribe_app on your WhatsApp Business Account so Meta streams every event for that number to Telenow. A background re-subscribe worker re-asserts that subscription periodically, so a number never silently stops delivering after a Meta-side reset.
Telenow ingests each raw Cloud API event, normalises it, fetches media, resolves the thread, and fans it out to your subscriptions as one of three topics. Your only job is to subscribe (once) and verify signatures.
Meta Cloud API ──subscribe_app──► Telenow ingest ──► normalise + fetch media ──► your endpoint
POST /api/v1/hooks · X-VoiceAI-Signature
Building a published app instead of integrating your own number? Apps receive these same three topics as event webhooks declared in the app manifest (with the
The three event topics
Every WhatsApp webhook is one of these three event types. The field lists below are exhaustive at the top level — Telenow does not add undocumented top-level keys, and raw always carries Meta's untouched Cloud API object if you need something we didn't lift out. (The type values are not a closed enum — see the note below the content-shape table.)
There is no template-status webhook topic. To observe template approval, poll
GET /channels/{id}/templates— it lists only APPROVED templates.
whatsapp.message.received
Fires once per inbound message from a customer. Text arrives inline in body; richer types (media, interactive replies, button taps, reactions, location, contacts) are decoded into the content object.
| Field | Type | Notes |
|---|---|---|
event | string | whatsapp.message.received |
channelId | string | The channel that received it — use as channelId when you reply |
channelPhone | string | Your business number, E.164 |
threadId | string | Conversation id — read history with GET /threads/{id}/messages |
messageId | string | Telenow's message row id |
wamid | string | Meta's message id (wamid.HBgL…) — react to or quote this |
from | string | Customer number, E.164 |
contactName | string | WhatsApp profile name, if shared |
type | string | text · image · audio · video · document · sticker · location · contacts · button · interactive · reaction |
body | string | Text body (empty for non-text types) |
timestamp | string | ISO-8601 receive time |
content | object | Structured extract for non-text types — see shapes below |
media | object | null | Always present as a key, one of three shapes: null for non-media types; { mime, filename, signedUrl } when the file fetched OK; or { mime, filename, fetchError } when the deferred download failed (no signedUrl, no id; mime/filename may be null). signedUrl itself can be null if signing fails, so always guard on media?.signedUrl, never media !== null. A media message emits a single received event, only after the fetch attempt resolves — there is no interim media:null for a media type. |
raw | object | Meta's original Cloud API message object |
Plain text:
{
"event": "whatsapp.message.received",
"channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"channelPhone": "+919876500000",
"threadId": "th_8c2a11f4",
"messageId": "a3c1e9d4-2f08-4b71-9e5a-6d2c1f7b0a91",
"wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQzQTdBQ0I…",
"from": "+919876543210",
"contactName": "Asha Rao",
"type": "text",
"body": "Where is my order #1234?",
"timestamp": "2026-07-18T09:14:02Z",
"content": null,
"media": null,
"raw": { "…": "Meta's raw message object" }
}
Image (media fetched):
{
"event": "whatsapp.message.received",
"channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"channelPhone": "+919876500000",
"threadId": "th_8c2a11f4",
"messageId": "b7e0c4a2-13f9-4d81-9a2e-5c6f0e21a4d2",
"wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQ5RkQ3…",
"from": "+919876543210",
"contactName": "Asha Rao",
"type": "image",
"body": "",
"timestamp": "2026-07-18T09:15:41Z",
"content": { "media": { "id": "media_5f31c9", "mime": "image/jpeg", "caption": "This arrived cracked" } },
"media": {
"mime": "image/jpeg",
"filename": "IMG_2043.jpg",
"signedUrl": "https://…"
},
"raw": { "…": "Meta's raw message object" }
}
Image (media fetch failed):
{
"event": "whatsapp.message.received",
"channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"channelPhone": "+919876500000",
"threadId": "th_8c2a11f4",
"messageId": "b7e0c4a2-13f9-4d81-9a2e-5c6f0e21a4d3",
"wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQ5RkQ3…",
"from": "+919876543210",
"contactName": "Asha Rao",
"type": "image",
"body": "",
"timestamp": "2026-07-18T09:15:41Z",
"content": { "media": { "id": "media_5f31c9", "mime": "image/jpeg", "caption": "This arrived cracked" } },
"media": {
"mime": "image/jpeg",
"filename": null,
"fetchError": "media download failed"
},
"raw": { "…": "Meta's raw message object" }
}
content carries the structured decode for every non-text type:
type | content shape |
|---|---|
| media (image/audio/video/document/sticker) | { "media": { "id", "mime", "caption"?, "filename"?, "voice"? } } — voice:true marks a voice note |
location | { "location": { "latitude", "longitude", "name"?, "address"? } } |
interactive (reply-button or list tap) | { "reply": { "kind": "button_reply" | "list_reply", "id", "title" } } |
button (quick-reply from a template) | { "button": { "payload", "text" } } |
reaction | { "reaction": { "message_id", "emoji" } } — empty emoji = reaction removed |
contacts | { "contacts": [ … ] } — Meta contact-card array |
typeis not a closed enum. For inbound Meta message kinds Telenow doesn't decode (e.g.system,order,request_welcome,unsupported,ephemeral),typecarries Meta's raw type string,bodyis[<type>], andcontentisnull. Include a default branch that reads the full untouched object fromrawso an unrecognisedtypenever drops the message.
A customer tapping a list row you sent looks like this — route on the id you assigned when you sent the interactive message:
{
"type": "interactive",
"body": "",
"content": { "reply": { "kind": "list_reply", "id": "track_order", "title": "Track my order" } }
}
Media download. For the webhook flow, fetch the bytes from
media.signedUrl— a short-lived presigned blob-store URL. To re-pull later, callGET /api/app-whatsapp/media/{id}with the message'smessageId(the Telenow row UUID from GET /threads/{id}/messages) as{id}. The/media/{id}route resolves{id}as that Telenow message-row id, not Meta'scontent.media.id— Meta's media id is not accepted. Inbound voice-note transcription is not built yet; a voice note arrives ascontent.media.voice = truewith the audio only.
whatsapp.message.status
Fires on forward progress for a message you sent — sent → delivered → read, or failed. It never repeats a status you already got. This is where you learn a send failed after the API already returned 200 with a wamid.
| Field | Type | Notes |
|---|---|---|
event | string | whatsapp.message.status |
channelId | string | Sending channel |
channelPhone | string | Your business number |
wamid | string | The message this status is about — match it to the wamid your send returned |
status | string | sent · delivered · read · failed |
recipient | string | Customer number, E.164 |
timestamp | string | ISO-8601 |
errors | array | null | Present on failed — Meta's full error object forwarded verbatim: code, title, message, error_data.details (the human-actionable specifics), and href (link to Meta's docs, when present) |
pricing | object | null | Meta's billing category for the conversation |
conversation | object | null | Meta conversation id + origin type |
Delivered:
{
"event": "whatsapp.message.status",
"channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"channelPhone": "+919876500000",
"wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgARGBI5QTND…",
"status": "delivered",
"recipient": "+919876543210",
"timestamp": "2026-07-18T09:14:05Z",
"errors": null,
"pricing": { "billable": true, "category": "utility", "pricing_model": "CBP" },
"conversation": { "id": "b1f4…", "origin": { "type": "utility" } }
}
Failed — 24-hour window closed:
{
"event": "whatsapp.message.status",
"channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"channelPhone": "+919876500000",
"wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgARGBI3Q0Ez…",
"status": "failed",
"recipient": "+919876543210",
"timestamp": "2026-07-18T12:41:10Z",
"errors": [
{
"code": 131047,
"title": "Re-engagement message",
"message": "Message failed to send because more than 24 hours have passed since the customer last replied to this number.",
"error_data": { "details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number." },
"href": "https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/"
}
],
"pricing": null,
"conversation": null
}
Full code→fix table lives in Errors & limits.
whatsapp.account.health
Fires when Meta changes something about your account or number — a quality rating shift, a messaging-limit tier change, or a review outcome. It carries the changed field, not a full snapshot: treat it as a poke to re-read your live health endpoint.
| Field | Type | Notes |
|---|---|---|
event | string | whatsapp.account.health |
wabaId | string | Your WhatsApp Business Account id |
field | string | account_update · phone_number_quality_update · account_review_update |
value | object | Meta's change payload for that field |
{
"event": "whatsapp.account.health",
"wabaId": "104729…",
"field": "phone_number_quality_update",
"value": { "display_phone_number": "+919876500000", "event": "FLAGGED", "current_limit": "TIER_1K" }
}
value is Meta's raw change payload for the fired field, passed through verbatim, so its shape differs per field. account_update carries ban/disable events — an event such as DISABLED_UPDATE or ACCOUNT_DELETED plus account-status fields; account_review_update carries the review decision. See Meta's account_update / account_review_update schema rather than relying on this example, and re-read your live health via Account health.
Subscribe — Org REST-hooks
For your own backend (or an automation platform like Zapier, n8n or Make — see Guide: automation platforms). Create the subscription with an org API key — a vai_live_… secret you generate in the dashboard under Developers → API keys. This is a different credential from the vai_app_… app key used to send messages: send messages with the app key, subscribe to webhooks with the org key. Hook management needs the key's role to be owner, admin, or developer (viewer keys are read-only here). You then verify deliveries with the returned secret. This is the same REST-hooks surface documented in Guide: receive & verify webhooks.
curl -X POST https://api.telenow.ai/api/v1/hooks \
-H "X-API-Key: vai_live_…" \
-H "Content-Type: application/json" \
-d '{
"events": ["whatsapp.message.received", "whatsapp.message.status", "whatsapp.account.health"],
"target_url": "https://example.com/telenow/whatsapp"
}'
{
"id": "hook_3a91f0",
"target_url": "https://example.com/telenow/whatsapp",
"events": ["whatsapp.message.received", "whatsapp.message.status", "whatsapp.account.health"],
"agent_id": null,
"include_recording": false,
"include_transcript": false,
"source": "api",
"created_at": "2026-07-18T12:00:00Z",
"signing_secret": "wh_secret_9c1f7b2e4a…",
"secret": "wh_secret_9c1f7b2e4a…"
}
The /api/v1 response is flat — read the secret from the top-level signing_secret (the secret key holds the same value), not data.signing_secret.
Save
signing_secretnow — thewh_secret_…value is returned once and never shown again. You need it to verify every delivery.
Rules for WhatsApp subscriptions:
- Omit
agent_id. WhatsApp events are org-wide — they aren't tied to a single agent. Passing anagent_idwould scope the hook to that agent and you'd receive nothing. - Subscribe only to the topics you handle; unlisted events aren't delivered.
- Manage hooks with
GET /api/v1/hooks,GET /api/v1/hooks/:id,DELETE /api/v1/hooks/:id. - Preview a payload shape without waiting for real traffic:
GET /api/v1/events/sample?type=whatsapp.message.received.
Delivery headers on every POST to your target_url:
| Header | Value |
|---|---|
X-VoiceAI-Signature | sha256=<hex> — HMAC of the raw body (see verification) |
X-VoiceAI-Event | The topic, e.g. whatsapp.message.received |
X-VoiceAI-Delivery | Unique delivery id — dedupe on this |
The request body is the topic payload exactly as shown above — no extra envelope.
Verify the signature
Every delivery is signed with HMAC-SHA256 over the raw request bytes, keyed with your signing_secret, hex-encoded, in the X-VoiceAI-Signature header. Compute the expected value, strip the sha256= prefix from the header if present, and compare in constant time. Never verify against a re-serialized object — whitespace and key order differ and the signature won't match.
Building a published app? App deliveries use the same HMAC-SHA256 scheme but a different header (
X-Telenow-Signature) and an enveloped body — see App events.
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const e = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header || ""), b = Buffer.from(e);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Wire it to the raw body — in Express, capture bytes before JSON parsing:
const express = require("express");
const app = express();
app.use("/telenow/whatsapp", express.raw({ type: "application/json" }));
app.post("/telenow/whatsapp", (req, res) => {
const sig = req.get("X-VoiceAI-Signature"); // app webhooks: req.get("x-telenow-signature")
if (!verify(req.body, sig, process.env.TELENOW_WA_SECRET)) {
return res.status(401).end(); // reject unsigned / tampered
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // ack fast, then process async
handleWhatsAppEvent(req.get("X-VoiceAI-Delivery"), event);
});
Delivery semantics
WhatsApp webhooks are at-least-once. Design your handler to be idempotent.
- Ack fast, work async. Return
2xxwithin a couple of seconds, then do your processing off the request path. A slow handler looks like a failure and gets retried. - Dedupe on the delivery id. The same event can arrive more than once (retry after a slow ack, network blip). Record
X-VoiceAI-Delivery(or the app-webhook message id) and skip duplicates. Do not dedupe onwamidfor status events — onewamidlegitimately yields several statuses (sent, thendelivered, thenread). - Retry & backoff. Undelivered events are retried on a ~5-second poll, up to 8 attempts, with backoff stretching out to 1 hour. A non-
2xxresponse or a timeout counts as a failed attempt. 410disables the hook. If your endpoint returns HTTP410 Gone, Telenow treats the subscription as dead and stops delivering. Return410only when you truly want the hook torn down; return500/503for transient problems so retries continue.
Worked example: reply to an inbound message
The most common flow — a customer messages you, you decide, you reply. Because the customer just messaged, the 24-hour service window is open, so you can reply with free-form text (no template needed).
1. Receive — Telenow POSTs whatsapp.message.received to your endpoint:
{
"event": "whatsapp.message.received",
"channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"from": "+919876543210",
"wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQzQTdBQ0I…",
"type": "text",
"body": "Where is my order #1234?"
}
2. Decide — verify the signature, dedupe on X-VoiceAI-Delivery, 200 immediately, then run your logic (look up the order). Optionally mark the inbound read and show a typing indicator while you work:
curl -X POST https://api.telenow.ai/api/app-whatsapp/messages/read \
-H "Authorization: Bearer vai_app_…" \
-H "Content-Type: application/json" \
-d '{ "channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQzQTdBQ0I…", "typing": true }'
3. Reply — send back to from on the same channelId (details in Send messages). To quote the inbound message so your answer threads under it, pass its wamid as reply_to (see Reply to a specific message):
curl -X POST https://api.telenow.ai/api/app-whatsapp/send \
-H "Authorization: Bearer vai_app_…" \
-H "Content-Type: application/json" \
-d '{ "channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
"to": "+919876543210",
"message": "Order #1234 shipped this morning — it should arrive by Friday.",
"reply_to": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQzQTdBQ0I…" }'
{ "success": true, "data": { "wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgERGBI0Rjc5…" } }
4. Track — a whatsapp.message.status event arrives for that returned wamid as it moves to delivered then read. If it comes back failed, read errors[0].code and act per Errors & limits.
Related
- WhatsApp API — the full endpoint surface.
- Send messages · Media · Templates · Inbox & threads · Broadcasts.
- Errors & limits — codes, rate limits, media caps, the 24-hour window.
- Guide: receive & verify webhooks — hardening a receiver end-to-end.
- Guide: automation platforms — subscribing from Zapier / n8n / Make.
- App events → WhatsApp — receive these same topics in an installable app (manifest
events[]+whatsappscope). - Authentication — app keys vs. org keys.