TryVox

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 sendTextMessage helper 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 as channel_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 4000 while 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.

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

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

webhookServer.js
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(), not express.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-Event is 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) alongside X-TryVox-Signature, kept only for integrations written before the current header existed. New integrations should verify X-TryVox-Signature as 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.

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

StatusMeaningAction
422Meta received the request and rejected it (bad template params, permission/verification issue, recipient not opted in)Fix the request — don't retry unchanged
502TryVox couldn't reach Meta at allTransient — retry with backoff
429Per-channel rate limit exceededRetry after the Retry-After header
403 OUTSIDE_CS_WINDOWTried text/media outside the 24-hour windowSend 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:

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

callingSettings.js
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:

requestCallPermission.js
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.received event with message_type: "interactive" (handled by step 4's handleInboundMessage) — interactive_payload carries { call_permission_reply: { response: "accept" | "reject", ... } }.
  • By polling GET /v1/messaging/calls/whatsapp/permissions?channel_id={id} (also unwrapped — a plain array) for a matching user_number with status: "accepted".

A granted permission is reused automatically — don't re-request it before every call.

List call history

callLogs.js
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:

realtime.js (extended)
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

On this page