How to set a group icon in Node.js with the Wapito WhatsApp API
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 the icon flow is a base64 data URI built from fs.promises.readFile, a POST that returns a media id, and a PUT that applies it to the group. The sharp package makes the square crop a one-liner and is worth adding, since a non-square image loses its edges to WhatsApp's circular crop.
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
Upload the image
Send a square JPEG or PNG and keep the source file to hand. A rectangular image is cropped by WhatsApp rather than letterboxed, so anything with text near the edge will lose it.
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/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6…","filename":"launch-team.png"}' }; try { const response = await fetch(url, options); const data = await response.json(); console.log(data); } catch (error) { console.error(error); }In Node.js read the file with await readFile(path), build the body with JSON.stringify({ data: `data:${mime};base64,${buffer.toString('base64')}`, filename }) and POST it; the parsed response gives you data.id, the media id. Use the mime-types package or a small lookup for the mime string rather than guessing from the extension by hand.
API reference for this stepSet it as the group icon
Apply the uploaded media to the group. The change is announced in the group, so avoid running this on a schedule that flips the icon back and forth.
const url = 'https://api.wapito.com/v1/groups/120363041234567890@g.us/icon'; const options = { method: 'PUT', headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'}, body: '{"media":"https://acme.example/brand/launch-team.png"}' }; try { const response = await fetch(url, options); const data = await response.json(); console.log(data); } catch (error) { console.error(error); }In Node.js apply the icon with fetch(url, { method: 'PUT', headers, body: JSON.stringify({ media_id }) }); PUT needs the JSON Content-Type header just as POST does. Check response.status: 403 means the number is not an admin and 415 means the upload was not an image the group accepts.
API reference for this stepRead the current icon
Fetch the current picture to show it in your own dashboard or to check whether an admin has replaced the one your automation set.
const url = 'https://api.wapito.com/v1/groups/120363041234567890@g.us/icon'; const options = {method: 'GET', headers: {Authorization: 'Bearer wpt_YOUR_TOKEN'}}; try { const response = await fetch(url, options); const data = await response.json(); console.log(data); } catch (error) { console.error(error); }In Node.js read the current icon with a GET on /groups/{id}/icon and compare the returned media reference with the id you set last time; store the comparison rather than the bytes. When they differ, an admin changed the picture from a phone and your dashboard should show theirs, not yours.
API reference for this stepRemove it when the group is retired
Clearing the icon is a cheap, visible signal that a group is closed, which works well alongside renaming it and locking it to admins only.
const url = 'https://api.wapito.com/v1/groups/120363041234567890@g.us/icon'; const options = {method: 'DELETE', headers: {Authorization: 'Bearer wpt_YOUR_TOKEN'}}; try { const response = await fetch(url, options); console.log(response.status); } catch (error) { console.error(error); }In Node.js clear the icon with method: 'DELETE'; the response is a 204 with an empty body, so calling response.json() throws. Test response.status === 204 and move on; combine it with the rename and the admin-only setting when a group is retired so the change is visible at a glance.
API reference for this step
The whole script
Every step above in one runnable file. Save it as group-icon.mjs, put your token in the environment, and run it.
// Group Icon with the Wapito WhatsApp API.
//
// Upload a square image, set it on the group, read it back for your dashboard, and clear it when the group is retired.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// node group-icon.mjs
const BASE_URL = 'https://api.wapito.com/v1';
const TOKEN = process.env.WAPITO_TOKEN;
// --- Upload the image ---
{
const url = BASE_URL + '/media';
const options = {
method: 'POST',
headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
body: '{"data":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6…","filename":"launch-team.png"}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
// --- Set it as the group icon ---
{
const url = BASE_URL + '/groups/120363041234567890@g.us/icon';
const options = {
method: 'PUT',
headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
body: '{"media":"https://acme.example/brand/launch-team.png"}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
// --- Read the current icon ---
{
const url = BASE_URL + '/groups/120363041234567890@g.us/icon';
const options = {method: 'GET', headers: {Authorization: 'Bearer ' + TOKEN}};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
// --- Remove it when the group is retired ---
{
const url = BASE_URL + '/groups/120363041234567890@g.us/icon';
const options = {method: 'DELETE', headers: {Authorization: 'Bearer ' + TOKEN}};
try {
const response = await fetch(url, options);
console.log(response.status);
} catch (error) {
console.error(error);
}
}
Receive the webhook
Express 5 receiver for groups — 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(['groups']);
/** 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));
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
- sharp(...).resize(512, 512) without a fit option distorts the image rather than cropping it; pass { fit: 'cover' } so the square is a crop and the logo keeps its proportions.
- Building the data URI with buffer.toString() and no 'base64' argument sends the raw bytes as UTF-8 text, and the API answers 400 invalid_request for an undecodable payload.
Frequently asked questions
What size should the image be?
Square, and large enough that the client's downscale looks clean rather than soft - a few hundred pixels on a side is plenty. WhatsApp compresses aggressively, so fine detail and small text will not survive; a simple mark on a solid background reads far better in a chat list.
Can I read the icon of a group I am not in?
No. Like everything else about a group, the icon is visible only to members. Resolving an invite code gives you the group's name and size before joining, but not its picture, so a preview screen has to make do with the metadata the code returns.
Does changing the icon notify everyone?
It appears as a system message in the group naming the admin who changed it, which every member sees in the thread. It does not usually generate a push notification, but it does take up a line in the conversation, so batch changes rather than flipping the icon repeatedly.
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.