How to send a message in Node.js with the Wapito WhatsApp API

Send the text you wrote, attach media by id, ask questions as polls, and treat the status webhook rather than the send response as delivery.

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.

Free-form sends in Node.js are one fetch per message with a JSON body, and the code around them is what keeps you safe: a for...of loop with await and a delay, a counter that stops the run at a cap, and an Express receiver that records delivery from the status event. fetch not throwing on 4xx is the trap to design around here.

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. Send the text you actually wrote

    One call, one message, no template id and no approval queue. The body is whatever you want to say, which means the copy can change with the deploy rather than with Meta's review cycle.

    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 send with fetch(url, { method: 'POST', headers, body: JSON.stringify({ to, text }) }), check response.ok, and keep data.id in a Map keyed by recipient for the status match. A template literal with the customer's name is all the templating there is, and the copy ships with your deploy.

    API reference for this step
  2. Attach media in the same flow

    Images, video, documents and voice notes all follow the same recipient-plus-payload shape. Upload once and reuse the media id when the same asset goes to many recipients.

    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 attach media by sending the media id and a caption in the same JSON shape; upload once with a separate call and reuse the id across a loop of recipients. Documents and voice notes use the same recipient-plus-payload body with a different media behind the id.

    API reference for this step
  3. Ask a structured question

    A poll turns a question into machine-readable answers, which is far more reliable than asking people to reply with a number and then parsing whatever they type.

    const url = 'https://api.wapito.com/v1/messages/poll';
    const options = {
      method: 'POST',
      headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'},
      body: '{"to":"120363041234567890@g.us","title":"When should we run the launch standup?","options":["Monday 09:00","Tuesday 10:00","Wednesday 16:00"],"multiple":false}'
    };
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js ask a structured question with a POST to /messages/poll carrying the question and an options array; store the returned id with the recipient, because vote events reference it. Tallying is then a Map lookup rather than a regular expression over whatever someone typed.

    API reference for this step
  4. Follow delivery on the webhook

    A 2xx from the send endpoint means accepted, not delivered. The status event carries the real outcome keyed by message id, and it is what your retry logic should watch.

    Arrives on your webhook as messages.status.

    In Node.js the Express handler receives messages.status events; find payload.data.id in your store, set the delivered or failed state, and res.sendStatus(200). A retry queue should read that state, since the send call's 2xx means accepted and nothing more.

The whole script

Every step above in one runnable file. Save it as send-message-without-template.mjs, put your token in the environment, and run it.

// Send Without Template with the Wapito WhatsApp API.
//
// Send the text you wrote, attach media by id, ask questions as polls, and treat the status webhook rather than the send response as delivery.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   node send-message-without-template.mjs

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

// --- Send the text you actually wrote ---
{
  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);
  }
}

// --- Attach media in the same flow ---
{
  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);
  }
}

// --- Ask a structured question ---
{
  const url = BASE_URL + '/messages/poll';
  const options = {
    method: 'POST',
    headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
    body: '{"to":"120363041234567890@g.us","title":"When should we run the launch standup?","options":["Monday 09:00","Tuesday 10:00","Wednesday 16:00"],"multiple":false}'
  };

  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, polls — 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', 'polls']);

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

  • Promise.all over a recipient list defeats the per-channel queue and half the sends come back 429 send_rate_limited; loop with await and setTimeout from node:timers/promises.
  • A 402 plan_required arrives as a resolved promise with a JSON error body; a loop that only awaits fetch will send nothing and log nothing until you check response.ok on every call.
  • Running the script against the production token while developing is how a test loop reaches customers; read the token from an environment variable that points at a Sandbox channel by default.

Frequently asked questions

Is sending without a template against WhatsApp's rules?

It is outside the official platform, which is a different statement. Wapito drives a real linked device, the same way the desktop app does, and WhatsApp can ban a number it judges to be misbehaving. No provider can promise otherwise, which is why every page here carries a ban-risk note rather than a guarantee.

What replaces the 24-hour window?

Nothing technical - and that is precisely why your own discipline has to. The window existed to stop businesses messaging people who had not asked. Keep an opt-in record, honour opt-outs immediately, and pace first contacts, because the abuse systems still exist even when the window does not.

Can I still use templates if I want to?

There is no template system here to use, because there is no approval layer. If your use case genuinely fits templates - predictable transactional notices to customers who expect them - the official Cloud API is the better tool and we will say so on the comparison page.

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.