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

Post a text card, or upload media once and post it as an image, video or voice status with a caption that carries the detail.

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.

Status posting in Node.js is four fetch calls with JSON bodies; the upload is a base64 data URI from fs.promises.readFile, and the media id it returns feeds the media and audio posts. A daily job on a scheduler suits it, and nothing here needs a long-lived process because there are no receipts to wait for.

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. Post a text card

    A text status is a short line on a coloured background. Keep it under a couple of dozen words: the card is read in a second by someone tapping through, not studied.

    const url = 'https://api.wapito.com/v1/stories/text';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"body":"Workshop closed Friday for stocktake. Orders ship Monday.","background_color":"#0B7F5C","font":2,"contacts":["+15551234567","+15559876543"]}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js post a text card with fetch(url, { method: 'POST', headers, body: JSON.stringify({ text, background }) }) and keep data.id from the parsed reply. Guard the length in code with a word count, because a card is read in a second and a paragraph on it is skipped.

    API reference for this step
  2. Upload the image or video

    Upload once and reuse the media id if you post the same asset to status and to a channel. Portrait assets fill the screen; landscape ones are letterboxed and look like an afterthought.

    const url = 'https://api.wapito.com/v1/media';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"data":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD…","filename":"new-colours.jpg"}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js build the upload body as JSON.stringify({ data: `data:${mime};base64,${(await readFile(path)).toString('base64')}`, filename }) and POST it; data.id in the reply is the media id you reuse for the status and, if you like, a channel post in the same run.

    API reference for this step
  3. Post the media status

    Attach the uploaded media with a caption. This is the format shops use for a daily menu or new stock, because the picture does the work and the caption carries the price or the time.

    const url = 'https://api.wapito.com/v1/stories/media';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"media":"https://acme.example/status/new-colours.jpg","caption":"New colours, same price.","contacts":["+15551234567"]}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js post the media status with the media id and caption in the body and read response.status: 413 and 415 arrive as resolved promises, so an if on response.ok with the JSON error code in the log is what tells you a file was too large or the wrong type.

    API reference for this step
  4. Post a voice status

    A short voice note as a status is unusual enough to get attention and personal enough to be worth it occasionally. The audio must be Opus-encoded, the same as a voice message.

    const url = 'https://api.wapito.com/v1/stories/audio';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"media":"https://acme.example/status/monday-update.m4a","background_color":"#1D2B3A"}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js a voice status needs Opus audio; spawn ffmpeg with child_process.execFile to convert first, upload the result, and POST the media id to /stories/audio. An MP3 comes back as 415 unsupported_media_type, which fetch will not throw for, so check the status.

    API reference for this step

The whole script

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

// Post Status with the Wapito WhatsApp API.
//
// Post a text card, or upload media once and post it as an image, video or voice status with a caption that carries the detail.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   node post-status.mjs

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

// --- Post a text card ---
{
  const url = BASE_URL + '/stories/text';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"body":"Workshop closed Friday for stocktake. Orders ship Monday.","background_color":"#0B7F5C","font":2,"contacts":["+15551234567","+15559876543"]}'
  };

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

// --- Upload the image or video ---
{
  const url = BASE_URL + '/media';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"data":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD…","filename":"new-colours.jpg"}'
  };

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

// --- Post the media status ---
{
  const url = BASE_URL + '/stories/media';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"media":"https://acme.example/status/new-colours.jpg","caption":"New colours, same price.","contacts":["+15551234567"]}'
  };

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

// --- Post a voice status ---
{
  const url = BASE_URL + '/stories/audio';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"media":"https://acme.example/status/monday-update.m4a","background_color":"#1D2B3A"}'
  };

  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

  • Buffer.toString('base64') on a large video allocates a string a third larger than the file, and a JSON.stringify around it doubles that; check the file size before reading and skip anything above the cap.
  • A scheduler that runs the job at server midnight posts the status at the wrong local hour; compute the trigger in the number's timezone with a cron library that accepts one.

Frequently asked questions

Who actually sees my status?

People who have saved your number in their contacts and have not restricted status from you. There is no subscriber list and no way to add someone, so growing reach means getting more people to save your number - which is a marketing problem rather than an API one.

Can I schedule status posts?

Not inside WhatsApp, but that is exactly what the API is for: run a scheduler on your side and call the endpoint at the moment you want the post to appear. Since statuses expire after a day, posting at the right hour matters more here than for almost anything else.

Does posting status count as sending?

It is activity on the linked number and is paced through the same queue, so treat it as part of your daily budget rather than as free. It does not consume a per-recipient send the way a broadcast would, which is part of why it is an efficient way to reach saved contacts.

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.