How to get a profile photo 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 avatar handling is two GETs, a PATCH with an uploaded image, and an Express route that evicts a cache entry when a contact changes theirs. fetch resolves the empty-picture case like any other response, so the wrapper returns null on purpose, and the cache is a Map keyed by contact id with a fetched-at timestamp beside each entry.
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
Read a contact's profile
Fetch the display name and picture for a contact. Privacy settings apply: a person who shows their photo only to contacts will return nothing to a number they have not saved, and that is a normal result rather than a failure.
const url = 'https://api.wapito.com/v1/contacts/+15551234567/profile'; 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 a contact's profile with a GET on /contacts/{id}/profile and return data.picture ?? null from your wrapper; a withheld photo is privacy, not an error, and the display name on the same object is still useful for an agent view. Check response.status for 404, which means the contact is not on WhatsApp.
API reference for this stepRead a chat picture
The same call pattern works for a chat, which covers groups as well as people. Use it to populate an agent dashboard so a human sees the same avatar they would see on their phone.
const url = 'https://api.wapito.com/v1/chats/15551234567@s.whatsapp.net/picture'; 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 the chat picture is a GET on /chats/{id}/picture with the same shape, so one function taking the path works for contacts and groups; store the returned URL in a Map with Date.now() so a dashboard can show an age and refresh stale entries.
API reference for this stepSet the linked number's own photo
Upload a square image and set it as the number's own picture. A number with a real photo and a real name looks like a business rather than a burner, which measurably affects how people respond to it.
const url = 'https://api.wapito.com/v1/users/profile'; const options = { method: 'PATCH', headers: {Authorization: 'Bearer wpt_YOUR_TOKEN', 'Content-Type': 'application/json'}, body: '{"name":"Acme Support","status":"Replies Mon-Fri, 9 to 6 UK time."}' }; try { const response = await fetch(url, options); const data = await response.json(); console.log(data); } catch (error) { console.error(error); }In Node.js set your own number's photo with fetch(url, { method: 'PATCH', headers, body: JSON.stringify({ picture: mediaId }) }) after uploading a square image made with sharp's fit: 'cover'. A 415 in response.status means the image type is not accepted; fetch will not throw for it.
API reference for this stepRefresh when a contact changes theirs
Contact updates arrive as events, so a cached avatar can be invalidated at the moment it changes instead of being re-fetched on a timer for thousands of contacts.
Arrives on your webhook as
contacts.In Node.js the Express handler receives contacts events; delete the contact's id from the Map, or the row from your store, and res.sendStatus(200). The next read repopulates it, so a thousand contacts never need a timer-driven refresh.
The whole script
Every step above in one runnable file. Save it as profile-picture.mjs, put your token in the environment, and run it.
// Profile Picture with the Wapito WhatsApp API.
//
// Read a contact or chat picture where privacy allows, set your own number's photo, and refresh your cache from contact events.
//
// Run it with:
// export WAPITO_TOKEN="wpt_..."
// node profile-picture.mjs
const BASE_URL = 'https://api.wapito.com/v1';
const TOKEN = process.env.WAPITO_TOKEN;
// --- Read a contact's profile ---
{
const url = BASE_URL + '/contacts/+15551234567/profile';
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);
}
}
// --- Read a chat picture ---
{
const url = BASE_URL + '/chats/15551234567@s.whatsapp.net/picture';
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);
}
}
// --- Set the linked number's own photo ---
{
const url = BASE_URL + '/users/profile';
const options = {
method: 'PATCH',
headers: {Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json'},
body: '{"name":"Acme Support","status":"Replies Mon-Fri, 9 to 6 UK time."}'
};
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 contacts — 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(['contacts']);
/** 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
- A Map cache in one process is invisible to the other replicas on a platform that runs several; a redeploy also empties it, so anything beyond a single container needs Redis.
- Caching null with no timestamp freezes a withheld avatar forever; store the fetch time with the value and expire it after the contacts event or a long interval.
Frequently asked questions
Why does a contact's photo come back empty?
Almost always because of their privacy settings. WhatsApp lets everyone choose who can see their picture, and a linked number that is not in their contacts will often see nothing. It is the expected outcome for a cold contact rather than something to retry or work around.
How large should my own profile picture be?
Square and a few hundred pixels on a side is plenty; WhatsApp compresses it heavily and renders it in a small circle. A simple mark on a solid background survives that treatment far better than a detailed photograph or anything with small text in it.
Can I read the picture of a group?
Yes, through the chat picture call, provided the linked number is a member of that group. For groups the picture is the icon an admin set, and there is a dedicated group icon endpoint if you also need to change it rather than only read it.
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.