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/hooks with that key and your target_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.

Which numbers fire events. whatsapp.message.received and whatsapp.message.status fire for Meta Cloud numbers — native ("set up for you") or connected through your own Meta app. Numbers connected through GupShup, Plivo, AiSensy or WhatsApp Web don't produce webhook events; read their conversations with the org API key instead. whatsapp.account.health fires for native numbers only.

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 whatsapp scope), delivered per installing org. That path is documented in App events → WhatsApp. The rest of this page covers the org REST-hooks path — your own backend receiving your own numbers' messages.


The three event topics

Every WhatsApp webhook is one of these three event types. The field lists below cover the top level — plus two keys on every delivery: orgId (your org) and occurredAt (when Telenow queued the event, ISO-8601 UTC). 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 the templates list — the app-key list shows only approved templates; the org-key list shows every status, including rejected.

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.

FieldTypeNotes
eventstringwhatsapp.message.received
channelIdstringThe channel that received it — use as channelId when you reply
channelPhonestringYour business number, E.164
threadIdstringConversation id (UUID) — read history with GET /threads/{id}/messages, or reply with the org-key …/threads/{threadId}/send
messageIdstringTelenow's message id (UUID) — the {id} for re-downloading media
wamidstringMeta's message id (wamid.HBgL…) — react to or quote this
fromstringCustomer number, E.164
contactNamestringWhatsApp profile name, if shared
typestringtext · image · audio · video · document · sticker · location · contacts · button · interactive · reaction · order (and others — see below)
bodystringThe text — for other types a short stand-in: the caption or [image] / [video], [voice message], the file name or [document], [location], [sticker], [contacts], [reaction], the button text, the reply's title, or [form response]
timestampstringWhen the customer sent it (Meta's time), RFC 3339 with +00:00
contentobjectStructured extract for non-text types — see shapes below
mediaobject | nullAlways 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.
rawobjectMeta's original Cloud API message object

Plain text:

{
  "event": "whatsapp.message.received",
  "channelId": "9f2c7b1e-4a10-4c3d-8f21-77e0a5b8c001",
  "channelPhone": "+919876500000",
  "threadId": "8c2a11f4-6b3e-4d9a-a1c7-2e5f9b0d3a61",
  "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:02+00:00",
  "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": "8c2a11f4-6b3e-4d9a-a1c7-2e5f9b0d3a61",
  "messageId": "b7e0c4a2-13f9-4d81-9a2e-5c6f0e21a4d2",
  "wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQ5RkQ3…",
  "from": "+919876543210",
  "contactName": "Asha Rao",
  "type": "image",
  "body": "This arrived cracked",
  "timestamp": "2026-07-18T09:15:41+00:00",
  "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": "8c2a11f4-6b3e-4d9a-a1c7-2e5f9b0d3a61",
  "messageId": "b7e0c4a2-13f9-4d81-9a2e-5c6f0e21a4d3",
  "wamid": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQ5RkQ3…",
  "from": "+919876543210",
  "contactName": "Asha Rao",
  "type": "image",
  "body": "This arrived cracked",
  "timestamp": "2026-07-18T09:15:41+00:00",
  "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:

typecontent shape
media (image/audio/video/document/sticker){ "media": { "id", "mime", "caption"?, "filename"?, "voice"?, "sha256"?, "animated"? } } — voice:true marks a voice note, animated an animated sticker
location{ "location": { "latitude", "longitude", "name", "address" } } — name/address are null when not shared
interactive (reply-button or list tap){ "reply": { "kind": "button_reply" | "list_reply", "id", "title", "description" } }
interactive (a WhatsApp Flow form submitted){ "nfm_reply": { "name", "body", "answers", "response_json" } }
button (quick-reply from a template){ "button": { "payload", "text" } }
reaction{ "reaction": { "message_id", "emoji" } } — empty emoji = reaction removed
contacts{ "contacts": [ … ] } — Meta contact-card array
order{ "order": { … } } — Meta's order object; body is the customer's note or [order: N item(s)]

type is not a closed enum. For inbound Meta message kinds Telenow doesn't decode (e.g. system, request_welcome, unsupported, ephemeral), type carries Meta's raw type string, body is [<type>], and content is null. Include a default branch that reads the full untouched object from raw so an unrecognised type never 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": "Track my order",
  "content": { "reply": { "kind": "list_reply", "id": "track_order", "title": "Track my order", "description": null } }
}

Media download. For the webhook flow, fetch the bytes from media.signedUrl — a presigned link that works for about 1 hour. To re-pull later, call GET /api/app-whatsapp/media/{id} — or, with your org API key, GET /api/orgs/{orgId}/whatsapp-hub/media/{id} — with the event's messageId as {id}. The /media/{id} routes resolve {id} as that Telenow message id, not Meta's content.media.id — Meta's media id is not accepted. Inbound voice-note transcription is not built yet; a voice note arrives as content.media.voice = true with 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, and a status that arrives out of order is dropped — if WhatsApp reports delivered before sent, you never get sent. This is where you learn a send failed after the API already returned 200 with a wamid.

FieldTypeNotes
eventstringwhatsapp.message.status
channelIdstringSending channel
channelPhonestringYour business number
wamidstringThe message this status is about — match it to the wamid your send returned
statusstringsent · delivered · read · failed
recipientstringCustomer number, E.164
timestampstringRFC 3339 with +00:00
errorsarray | nullPresent 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)
pricingobject | nullMeta's billing category for the conversation
conversationobject | nullMeta 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:05+00:00",
  "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:10+00:00",
  "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 — for native numbers. It carries the changed field, not a full snapshot: treat it as a poke to re-read your live health endpoint. When Meta re-sends a notification, the event fires again with a new X-VoiceAI-Delivery, so deduping on the delivery id won't catch those repeats — compare field and value if that matters.

FieldTypeNotes
eventstringwhatsapp.account.health
wabaIdstringYour WhatsApp Business Account id
fieldstringaccount_update · phone_number_quality_update · account_review_update
valueobjectMeta'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. The same key can also send and read messages, so one key covers the whole loop. Creating and deleting hooks needs the key's role to be owner, admin, or developer (viewer keys can only list). 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": "3a91f0c2-7d4e-4b1a-9f6c-0e2d8a5b7c13",
  "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 (HTTP 201) — read the secret from the top-level signing_secret (the secret key holds the same value), not data.signing_secret. targetUrl and url are accepted for target_url; the URL must be a public https address (400 webhook url rejected: … otherwise).

Save signing_secret now — the wh_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 an agent_id would scope the hook to that agent and you'd receive nothing.
  • List the topics you want. Unlisted events aren't delivered — but leaving events out entirely subscribes you to every event (["*"]), calls and billing included.
  • Manage hooks with GET /api/v1/hooks (→ { hooks, total }, any key role) and DELETE /api/v1/hooks/:id (→ { id, deleted }, safe to repeat). A hook disabled by a 410 disappears from the list.
  • Preview a payload shape without waiting for real traffic: GET /api/v1/events/sample?type=whatsapp.message.received returns { event_type, is_real, samples } — your org's latest real payload of that type when there is one, otherwise a built-in example.

Delivery headers on every POST to your target_url:

HeaderValue
X-VoiceAI-Signaturesha256=<hex> — HMAC of the raw body (see verification)
X-VoiceAI-EventThe topic, e.g. whatsapp.message.received
X-VoiceAI-DeliveryUnique 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 2xx within 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 on wamid for status events — one wamid legitimately yields several statuses (sent, then delivered, then read).
  • Retry & backoff. A delivery gets 8 attempts in total. The waits between them double from 10 seconds — 10 s, 20 s, 40 s … 640 s — so the last attempt comes about 21 minutes after the first; after that the delivery is dropped. A non-2xx response, a timeout (30 seconds) or a redirect (3xx isn't followed) counts as a failed attempt. Running out of attempts doesn't disable the hook — later events are still delivered.
  • 410 disables the hook. If your endpoint returns HTTP 410 Gone, Telenow treats the subscription as dead and stops delivering. Return 410 only when you truly want the hook torn down; return 500/503 for 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 (an app-key route — there's no org-key equivalent):

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…" } }

With your org API key, reply into the conversation by its threadId from the event — no number or channel needed:

curl -X POST https://api.telenow.ai/api/orgs/{orgId}/whatsapp-hub/threads/{threadId}/send \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Order #1234 shipped this morning — it should arrive by Friday.",
        "reply_to": "wamid.HBgLMTIxNTU1NTk4NzYVAgASGBQzQTdBQ0I…" }'

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.