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

Address the group id like any recipient, attach media by id, ask questions as polls, and aggregate the per-participant delivery receipts.

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.

Posting to a group in Node.js is one fetch call with the group id as the recipient, which is why the code that matters is the code around it: a sequential loop with a delay between groups, a media id uploaded once and reused, and an Express receiver that counts receipts per message instead of writing one row per participant.

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 text to the group id

    There is no separate group endpoint: you send to the group's id exactly as any other recipient. Mentioning participants inside the body is what turns a message into a notification for them specifically.

    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: groupId, text }) }) and keep data.id from the parsed response, because the status events reference it. Build mentions as fields of the payload object, not as text substitutions, and check response.ok since a 429 resolves like a success.

    API reference for this step
  2. Attach an image or a document

    Upload once and reuse the media id across groups rather than re-uploading the same file for each. A caption on the image carries far better than a separate text message immediately afterwards.

    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 post an image with the media id and caption in the JSON body; the upload itself is a separate call whose returned id you can hold in a variable and reuse across a loop of groups. Nothing here is multipart, so the request is the same shape as the text send with different fields.

    API reference for this step
  3. Ask with a poll rather than free text

    In a group, free-text replies from dozens of people are unusable. A poll returns structured votes keyed to the participant, which a bot can count without guessing what somebody meant.

    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 a poll is a POST to /messages/poll with the question and an options array in the body; the vote events arrive later carrying the message id. Keep a Map from that id to the group and question so a vote can be tallied against the right poll.

    API reference for this step
  4. Watch delivery and replies

    Group delivery receipts arrive per participant, so a status event stream from a large group is noisy. Aggregate rather than storing every receipt, and use the messages event for the replies you actually want.

    Arrives on your webhook as messages.status.

    In Node.js the Express handler receives a messages.status event per participant; increment a counter in a Map keyed by message id and flush it with setInterval rather than awaiting a database insert per event. Send res.sendStatus(200) before any I/O so a burst from a big group never causes redeliveries.

The whole script

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

// Send Group Message with the Wapito WhatsApp API.
//
// Address the group id like any recipient, attach media by id, ask questions as polls, and aggregate the per-participant delivery receipts.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   node send-group-message.mjs

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

// --- Send text to the group id ---
{
  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 an image or a document ---
{
  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 with a poll rather than free text ---
{
  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 the group list fires every send at once and the queue answers the overflow with 429 send_rate_limited; loop with await and a setTimeout from node:timers/promises between iterations.
  • A Map used as a receipt counter grows without bound in a long-running process; delete the key after the flush, or a week of group traffic becomes a memory leak.

Frequently asked questions

Is there a separate endpoint for group messages?

No, and that is deliberate. Every send endpoint takes a recipient, and a group id is simply one of the recipient forms it accepts. The same call that messages a person messages a group, which means your sending code does not need a special case for groups at all.

How do I mention someone in a group message?

Include the mention in the message body using the participant's identity, and the client renders it as a tap-able name that notifies them. Getting the identity right matters more than it used to, because in newer groups participants may be represented by a linked identity rather than a phone number.

Can I send to many groups at once?

Only by sending to each one in turn. The send queue serialises per channel deliberately, so a fan-out across fifty groups will be paced rather than parallel, and pushing harder returns a saturation error instead of sending faster. Build the loop to expect that pacing.

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.