Sample Integration
A complete, standard WhatsApp integration in Node.js — send templates and text, receive and read replies (including media), and verify every webhook delivery.
A complete, two-way WhatsApp integration in Node.js: send a templated message, reply with plain text once the customer responds, receive and read what they actually sent (text, media, or a button tap), and track delivery status — all through a signed webhook. Every snippet on this page is real code, run against TryVox's live API — copy it into a new project and it works as shown.
This covers what a standard WhatsApp integration needs day to day: the send/receive loop, reading inbound content and media, verified webhooks, and the backend half of WhatsApp Calling. For the rest of the API — campaigns, contacts — see the Messaging API reference.
What you'll build
- A script that sends a WhatsApp template message, and a
sendTextMessagehelper for replying once the customer responds - A webhook receiver that verifies each delivery's signature and reacts to
message.sent/message.delivered/message.read/message.failed/message.received - A handler that, on
message.received, fetches what the customer actually sent — text, an inbound image/document, or a button/list tap — since the webhook itself only tells you that a message arrived, not its content - Calling readiness checks, permission requests, and call history for WhatsApp Business Calling — everything backend-side short of the browser WebRTC session itself
Prerequisites
- A TryVox account with a WhatsApp channel connected — note its
id, you'll need it aschannel_id - A registered template approved by Meta (or use
hello_world, pre-approved on every WABA) - Node.js 18+ (for built-in
fetch) - A public HTTPS URL for the webhook receiver — use
ngrok http 4000while developing locally
1. Send messages
First contact on WhatsApp must use an approved template — plain text (and media) only work after the recipient has messaged you within the last 24 hours (the customer service window). See Send a message for the full reference on every message_type.
const TRYVOX_AUTH_ID = process.env.TRYVOX_AUTH_ID;
const TRYVOX_AUTH_TOKEN = process.env.TRYVOX_AUTH_TOKEN;
const CHANNEL_ID = process.env.TRYVOX_CHANNEL_ID;
const authHeader = "Basic " + Buffer.from(`${TRYVOX_AUTH_ID}:${TRYVOX_AUTH_TOKEN}`).toString("base64");
async function sendMessage(body) {
const res = await fetch("https://api.tryvox.io/v1/messaging/messages", {
method: "POST",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ channel_id: CHANNEL_ID, ...body }),
});
const parsed = await res.json();
if (!res.ok) {
const err = new Error(parsed?.error?.message || `TryVox returned ${res.status}`);
err.status = res.status;
err.code = parsed?.error?.code;
throw err;
}
return parsed.data;
}
async function sendTemplateMessage(to, templateName, templateParams = []) {
return sendMessage({
to,
message_type: "template",
template_name: templateName,
// A real array of Meta's component objects — never a JSON-stringified
// string, and never wrapped as {language, components}. TryVox reads
// the template's language from what you registered; you don't send
// it here.
template_params: templateParams,
});
}
// Only works inside the 24-hour customer service window — see step 3.
// Outside it, TryVox returns 403 OUTSIDE_CS_WINDOW; send a template instead.
async function sendTextMessage(to, content) {
return sendMessage({ to, message_type: "text", content });
}
try {
const message = await sendTemplateMessage("+14155551234", "hello_world");
console.log(`Accepted — message_id=${message.id}, status=${message.status}`);
} catch (err) {
if (err.status === 422) {
// Meta looked at the request and rejected it (bad params, permission or
// verification issue, recipient not opted in, ...). Fix the request —
// retrying unchanged fails the same way. err.message has Meta's reason.
console.error(`Rejected: ${err.message}`);
} else if (err.status === 502) {
// TryVox couldn't reach Meta at all — transient, safe to retry with backoff.
console.error(`Upstream unavailable: ${err.message}`);
} else {
console.error(`Send failed (${err.status}): ${err.message}`);
}
}template_params is only needed if the template has variables, e.g. [{"type": "body", "parameters": [{"type": "text", "text": "284731"}]}] for a one-variable body. hello_world has none, so an empty array works.
2. Subscribe to message events
One subscription covers every channel on your account. Omit secret and TryVox generates one — it's returned once, in this response only, so save it immediately.
const res = await fetch("https://api.tryvox.io/v1/notifications/webhooks", {
method: "POST",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://your-app.example.com/webhooks/tryvox",
events: ["message.sent", "message.received", "message.delivered", "message.read", "message.failed"],
}),
});
const { data } = await res.json();
console.log(`Subscribed. Save this secret — it won't be shown again: ${data.secret}`);Run this once per receiver URL, not on every deploy — re-subscribing the same URL creates a duplicate subscription and duplicate deliveries.
3. Receive and verify deliveries
Each delivery carries X-TryVox-Event (which event this is) and a signature you should verify before trusting the body. See Webhook Subscriptions for the full header/signature reference — this is a working receiver for it.
import crypto from "node:crypto";
import express from "express";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // from step 2
function verifySignature(rawBody, req) {
const header = req.header("X-TryVox-Signature"); // t=<timestamp>,v1=<hex>
if (!header) return false;
const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
if (!match) return false;
const [, timestamp, v1] = match;
// Reject stale deliveries — defeats replay attacks with a captured signature.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const signedContent = Buffer.concat([Buffer.from(timestamp), Buffer.from("."), rawBody]);
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(signedContent).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post(
"/webhooks/tryvox",
express.raw({ type: "application/json" }), // raw bytes — the signature covers the exact bytes TryVox sent
(req, res) => {
if (!verifySignature(req.body, req)) {
return res.status(401).json({ error: "invalid signature" });
}
const event = JSON.parse(req.body.toString("utf8")); // flat payload, no {event, data} wrapper
const type = req.header("X-TryVox-Event");
// Acknowledge immediately — TryVox retries on anything but 2xx, and
// doesn't wait for your business logic to finish.
res.status(200).json({ received: true });
switch (type) {
case "message.sent":
console.log(`Sent to ${event.phone_number}: whatsapp_message_id=${event.whatsapp_message_id}`);
break;
case "message.delivered":
console.log(`Delivered: message_id=${event.message_id}`);
break;
case "message.read":
console.log(`Read: message_id=${event.message_id}`);
break;
case "message.failed":
console.log(`Failed: message_id=${event.message_id} — ${event.error_code} ${event.error_message}`);
break;
case "message.received":
// The webhook only tells you a message arrived, not what it says —
// see step 4 for handleInboundMessage().
handleInboundMessage(event).catch((err) => console.error("failed to handle inbound message:", err));
break;
}
}
);
app.listen(4000, () => console.log("Webhook receiver listening on :4000/webhooks/tryvox"));A couple of things worth calling out because they're easy to get wrong:
- Use
express.raw(), notexpress.json(). The signature is computed over the exact bytes TryVox sent — parsing to JSON and re-serializing to check it produces different bytes and fails verification even with the right secret. - The body has no envelope. It's the event payload itself —
X-TryVox-Eventis how you know what it is, not a field inside the body. - TryVox also sends a legacy
X-Webhook-Signature: sha256=<hex>header (HMAC over the raw body alone, no timestamp) alongsideX-TryVox-Signature, kept only for integrations written before the current header existed. New integrations should verifyX-TryVox-Signatureas above and can ignore the legacy one.
4. Read what the customer actually sent
message.received only tells you a message arrived — message_id and conversation_id, nothing else. To get the text, an inbound image/document, or which button they tapped, fetch it from the conversation. See Conversations → Get messages for the full field reference.
async function getMessage(conversationId, messageId) {
// Messages come back newest-first, so the one the webhook just told you
// about is normally index 0 — but fetch a few and match by id in case
// another message landed in the moment between the webhook firing and
// this call.
const res = await fetch(
`https://api.tryvox.io/v1/messaging/conversations/${conversationId}/messages?limit=5`,
{ headers: { Authorization: authHeader } }
);
const { data } = await res.json();
return data.find((m) => m.id === messageId) ?? data[0];
}
async function downloadMedia(messageId) {
const res = await fetch(`https://api.tryvox.io/v1/messaging/messages/${messageId}/media`, {
headers: { Authorization: authHeader },
});
if (!res.ok) throw new Error(`media not ready or not found (${res.status})`);
return { contentType: res.headers.get("content-type"), bytes: Buffer.from(await res.arrayBuffer()) };
}
// TryVox broadcasts message.received as soon as the message row exists —
// deliberately before media finishes downloading from Meta, so the
// notification isn't held up by a slow transfer. That means has_media is
// very often still false the instant your webhook fires, for a message
// that succeeds moments later. Poll briefly rather than downloading once
// and giving up.
async function waitForMediaReady(conversationId, messageId, { retries = 5, delayMs = 1500 } = {}) {
for (let i = 0; i < retries; i++) {
const message = await getMessage(conversationId, messageId);
if (message.has_media) return true;
if (message.media_download_status === "failed") return false; // won't resolve on its own
await new Promise((r) => setTimeout(r, delayMs));
}
return false; // still pending after all retries — check back later
}
async function handleInboundMessage(event) {
const message = await getMessage(event.conversation_id, event.message_id);
if (!message) return;
switch (message.message_type) {
case "text":
console.log(`Text from ${event.phone_number}: "${message.content}"`);
// Real reply, not the webhook receiver's ack — this actually messages
// the customer back, and only works inside the 24-hour window.
await sendTextMessage(event.phone_number, `Got it: "${message.content}"`);
break;
case "image":
case "video":
case "audio":
case "document": {
const ready = await waitForMediaReady(event.conversation_id, message.id);
if (!ready) {
// Either still downloading past your retry budget, or Meta's fetch
// failed — POST /v1/messaging/messages/{id}/media/retry-download
// forces another attempt on a failed one.
console.log(`Media not ready for message ${message.id} (status: ${message.media_download_status})`);
break;
}
const { contentType, bytes } = await downloadMedia(message.id);
console.log(`Received ${message.message_type} (${contentType}, ${bytes.length} bytes)`);
// e.g. fs.writeFileSync(...) or upload to your own storage here.
break;
}
case "interactive":
// content is a human-readable summary (e.g. the button's label);
// interactive_payload has the full structured reply if you need to
// branch on a button/list id rather than the label text.
console.log(`Button/list reply: "${message.content}"`, message.interactive_payload);
break;
default:
console.log(`Unhandled message_type: ${message.message_type}`);
}
}A couple of things this depends on from earlier steps: authHeader from step 1, and sendTextMessage from step 1 to actually reply. event.phone_number (from the webhook payload) is the customer's number — use it for replying, not anything off the message object; the message itself only carries contact_id, an internal reference, not a phone number.
Error handling reference
| Status | Meaning | Action |
|---|---|---|
422 | Meta received the request and rejected it (bad template params, permission/verification issue, recipient not opted in) | Fix the request — don't retry unchanged |
502 | TryVox couldn't reach Meta at all | Transient — retry with backoff |
429 | Per-channel rate limit exceeded | Retry after the Retry-After header |
403 OUTSIDE_CS_WINDOW | Tried text/media outside the 24-hour window | Send a template instead |
Full list in Send a message → Errors.
Optional: real-time sync over WebSocket
Webhooks are enough for most integrations. If your app also wants a live socket feed (e.g. to push updates into a UI without polling), mint a short-lived ticket and connect:
const { data } = await (
await fetch("https://api.tryvox.io/v1/messaging/ws-ticket", { headers: { Authorization: authHeader } })
).json();
const ws = new WebSocket(`wss://api.tryvox.io/v1/messaging/ws?ticket=${data.ticket}`);
ws.onmessage = (msg) => console.log("realtime:", JSON.parse(msg.data));Tickets are one-time-use and short-lived — mint a fresh one on every reconnect, don't cache it.
Optional: WhatsApp Calling
WhatsApp Business Calling is a separate opt-in capability on top of messaging, gated by Meta per number. There's a real boundary worth being clear about up front: checking readiness, requesting permission, and tracking call history are plain backend REST calls — the snippets below do them for real. Actually placing or answering a call is not — Meta requires a live WebRTC SDP offer/answer, which only a browser RTCPeerConnection can produce. This sample can get you everything up to that point; for the browser + SIP side, see WhatsApp Business Calling.
Check calling readiness
async function getCallingSettings(channelId) {
const res = await fetch(`https://api.tryvox.io/v1/messaging/calls/whatsapp/settings?channel_id=${channelId}`, {
headers: { Authorization: authHeader },
});
// Not wrapped in {"data": ...} like most endpoints on this page — this
// one returns the settings object directly.
return res.json();
}
const settings = await getCallingSettings(CHANNEL_ID);
// `eligible`/`enabled`/`allow_business_initiated` can all read true before
// Meta has actually finished enabling calling for the number — they reflect
// what TryVox has provisioned, not Meta's live state. The authoritative
// signal is capabilities.calling_status:
if (settings.capabilities?.calling_status === "calling_ready") {
console.log("Calling is live for this number.");
} else {
console.log(`Not ready yet: ${settings.capabilities?.calling_status} — ${settings.capabilities?.reason_message ?? "check WhatsApp Manager"}`);
}Request permission to call a customer
Meta requires a customer's explicit permission before a business can call them — unless they've called you first, which implicitly grants it. Request it with an interactive WhatsApp message:
async function requestCallPermission(channelId, userNumber) {
const res = await fetch("https://api.tryvox.io/v1/messaging/calls/whatsapp/permissions", {
method: "POST",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ channel_id: channelId, user_number: userNumber }),
});
return res.json(); // also unwrapped — { status: "pending" | "accepted", ... }
}The customer's response arrives two ways — use whichever your app already has wired up:
- Through the webhook receiver from step 3, as a normal
message.receivedevent withmessage_type: "interactive"(handled by step 4'shandleInboundMessage) —interactive_payloadcarries{ call_permission_reply: { response: "accept" | "reject", ... } }. - By polling
GET /v1/messaging/calls/whatsapp/permissions?channel_id={id}(also unwrapped — a plain array) for a matchinguser_numberwithstatus: "accepted".
A granted permission is reused automatically — don't re-request it before every call.
List call history
async function listCallLogs(channelId) {
const res = await fetch(`https://api.tryvox.io/v1/messaging/calls?channel_id=${channelId}`, {
headers: { Authorization: authHeader },
});
const { data } = await res.json(); // this one IS wrapped, unlike settings/permissions above
return data;
}Each entry has direction, status (initiated, ringing, accepted, rejected, terminated, ...), duration_seconds, and timestamps — useful for a CRM view or billing reconciliation independent of the live call itself.
Tracking a call in progress
Calls placed through the browser don't fire the account-wide call.ended webhook from Webhook Subscriptions — that event covers calls routed through TryVox's SIP/CDR pipeline, not this REST+WebRTC flow. Instead, reuse the WebSocket connection from the previous section and watch for whatsapp_call.incoming, whatsapp_call.outgoing, whatsapp_call.status, and call.action:
ws.onmessage = (msg) => {
const event = JSON.parse(msg.data);
if (event.type.startsWith("whatsapp_call.") || event.type === "call.action") {
console.log("call event:", event.type, event.payload);
}
};Next steps
Channels
Connect a WhatsApp number
Templates
Register and manage templates
Conversations
Message history, media downloads, read state
Webhook Subscriptions
Full delivery format and retry reference
Send a message
Every message type and error code
WhatsApp Business Calling
Browser WebRTC + SIP architecture for placing calls