How to post to a channel in Node.js with the Wapito WhatsApp API

Send to the channel id like any recipient, upload media once and reuse it, prefer link posts for traffic, and confirm from the status event.

A script, a token and an HTTP client is the whole stack. Everything below runs from a file you can execute today, and the same code moves to a worker or a container without changing shape.

In Node.js posting to a Channel is a fetch to the normal message endpoints with the newsletter id as the recipient, one call per format. The script is mostly a scheduler and a store of message ids: media is uploaded once and referenced by id, posts are spaced out with await and a timer, and the Express receiver flips a post to published when its status event arrives.

Before you start

  • npm install express (fetch is built in from Node 18)
  • process.env.WAPITO_TOKEN
  • A channel with a WhatsApp number linked to it, and its API token. Create one in the dashboard — the authentication guide shows where the token goes.

How it works

  1. Publish a text post

    There is no dedicated publish endpoint: you send to the channel id exactly as you would to a person. Keep posts self-contained, because followers cannot ask a follow-up question in the thread.

    const url = 'https://api.wapito.com/v1/messages/text';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"to":"+15551234567","body":"Your order #4182 has shipped. Track it here: https://acme.example/t/4182","typing_time":3}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js publish with fetch(url, { method: 'POST', headers, body: JSON.stringify({ to: newsletterId, text }) }) and keep data.id from the parsed response for the status match later. Use a template literal for a multi-line post so the line breaks are real newlines, and check response.ok since a 429 will not throw.

    API reference for this step
  2. Post an image with a caption

    Upload once and reuse the media id if the same asset goes to several channels. The caption is the post, so write it as the whole message rather than as a label for the picture.

    const url = 'https://api.wapito.com/v1/messages/image';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"to":"+15551234567","media":"https://acme.example/labels/4182.png","caption":"Your shipping label for order #4182"}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js an image post is a POST with the media id and caption in the JSON body; upload the asset once with a separate call and reuse the id across channels within the same run. The caption is what followers read, so write it as the complete message rather than a filename.

    API reference for this step
  3. Share a link with a preview

    A link post with a preview is the highest-performing format for driving people off WhatsApp, which is usually the point of a channel. Put the destination on a URL you control so you can measure it.

    const url = 'https://api.wapito.com/v1/messages/link';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"to":"+15551234567","url":"https://acme.example/t/4182","title":"Track order #4182","description":"Out for delivery, arriving before 18:00.","image":"https://acme.example/og/tracking.png","body":"Your parcel is on the van:"}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js a link post is a POST to /messages/link with the URL and the preview text; build the URL with new URL(base) and url.searchParams.set for the campaign parameters so it points at a page you measure. The preview comes from that page's meta tags, so test it in a browser first.

    API reference for this step
  4. Confirm the post landed

    Channel posts produce their own status events. Use them to confirm publication and to key your own record of what went out and when, rather than trusting the send response alone.

    Arrives on your webhook as messages.status.

    In Node.js the Express handler receives the messages.status event for the post; look up payload.data.id in your store, set the published timestamp, and res.sendStatus(200). Treat the send response as accepted and this event as the confirmation, which is the distinction your dashboard should show.

The whole script

Every step above in one runnable file. Save it as post-to-channel.mjs, put your token in the environment, and run it.

// Post to Channel with the Wapito WhatsApp API.
//
// Send to the channel id like any recipient, upload media once and reuse it, prefer link posts for traffic, and confirm from the status event.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   node post-to-channel.mjs

const BASE_URL = 'https://api.wapito.com/v1';
const TOKEN = process.env.WAPITO_TOKEN;

// --- Publish a text post ---
{
  const url = BASE_URL + '/messages/text';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"to":"+15551234567","body":"Your order #4182 has shipped. Track it here: https://acme.example/t/4182","typing_time":3}'
  };

  try {
    const response = await fetch(url, options);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

// --- Post an image with a caption ---
{
  const url = BASE_URL + '/messages/image';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"to":"+15551234567","media":"https://acme.example/labels/4182.png","caption":"Your shipping label for order #4182"}'
  };

  try {
    const response = await fetch(url, options);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

// --- Share a link with a preview ---
{
  const url = BASE_URL + '/messages/link';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"to":"+15551234567","url":"https://acme.example/t/4182","title":"Track order #4182","description":"Out for delivery, arriving before 18:00.","image":"https://acme.example/og/tracking.png","body":"Your parcel is on the van:"}'
  };

  try {
    const response = await fetch(url, options);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

Receive the webhook

Express 5 receiver for messages, messages.status — it verifies the signature, answers immediately and does the work after. Install it with npm install express and save it as webhook.mjs.

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.WAPITO_WEBHOOK_SECRET;
const EVENTS = new Set(['messages', 'messages.status']);

/** Checks the X-Wapito-Signature header: t=<ms>,v1=<hex hmac-sha256>. */
function verify(raw, header) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() - Number(parts.t)) > 300_000) return false; // 5 minute clock skew
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(raw).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/wapito', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-Wapito-Signature'))) return res.sendStatus(401);
  const payload = JSON.parse(req.body.toString('utf8'));
  if (EVENTS.has(payload.event)) console.log(payload.event, payload.data);
  res.sendStatus(200); // answer 2xx fast; do the real work in a queue
});

app.listen(Number(process.env.PORT ?? 3000));
Every event, its payload and the retry rules

Errors you may hit

Gotchas

  • fetch never rejects on a 4xx or 5xx. Check response.ok yourself; an await fetch(...) that "worked" can still be a 402 plan_required with a JSON error body.
  • Do not let express.json() run before your webhook route. Signature verification needs the exact raw bytes, so mount express.raw({ type: "application/json" }) on the webhook path and parse the JSON yourself afterwards.
  • Node 18+ has global fetch, but node-fetch v2, undici and axios all disagree about how they surface non-2xx responses. Pick one client per project or your error handling will be inconsistent.
  • Promise.all over a list of recipients defeats the send queue: the API will serialise them anyway and return 429 send_rate_limited for the overflow. Send sequentially with await, or use a queue with concurrency 1 per channel.
  • Large numbers in JSON.parse lose precision. Treat every WhatsApp id, group id and timestamp that arrives as a string as a string - never Number() it.

Where to run it

  • Google Cloud Run (container, min instances 0, one process per channel worker)
  • Railway or Render web service for the Express receiver
  • Vercel or Netlify Functions for the webhook receiver only - they cannot hold the sender queue open
  • Fly.io machine with a persistent volume if you queue sends locally

Pitfalls in Node.js

  • setTimeout with a large delay is capped at about 24.8 days and fires immediately beyond that; a scheduler that sleeps until a distant post date needs a cron trigger, not a timer.
  • A JSON.stringify of a post object with an undefined caption drops the key entirely, which is fine, but an explicit null is sent as null and the API rejects it as invalid_request; delete the key instead.

Frequently asked questions

Is there a separate endpoint for channel posts?

No. The channel id is simply another recipient form accepted by the ordinary send endpoints, which means your publishing code is the same code that sends messages. The only difference is the id you address and the fact that nobody can reply to what you post.

Can I schedule posts in advance?

Not inside WhatsApp - there is no scheduled post object. Schedule it on your side with a job runner and call the send endpoint at the moment you want it published. That also means your scheduler, rather than WhatsApp, owns the retry behaviour if a post fails.

Can I edit or delete a post after publishing?

Editing and deleting follow the same rules as ordinary messages, so both are possible within WhatsApp's own time window and only for posts the linked number sent. Beyond that window the post stands, which is a good reason to have a human approve anything automated before it goes out.

Related

Try it on your own number

Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.