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/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.

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 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.

FieldTypeNotes
eventstringwhatsapp.message.received
channelIdstringThe channel that received it — use as channelId when you reply
channelPhonestringYour business number, E.164
threadIdstringConversation id — read history with GET /threads/{id}/messages
messageIdstringTelenow's message row id
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
bodystringText body (empty for non-text types)
timestampstringISO-8601 receive time
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": "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:

typecontent 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

type is not a closed enum. For inbound Meta message kinds Telenow doesn't decode (e.g. system, order, 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": "",
  "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, call GET /api/app-whatsapp/media/{id} with the message's messageId (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'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. 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
timestampstringISO-8601
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: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.

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. 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_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.
  • 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:

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. Undelivered events are retried on a ~5-second poll, up to 8 attempts, with backoff stretching out to 1 hour. A non-2xx response or a timeout counts as a failed attempt.
  • 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:

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.