WhatsApp API with Node.js: Full Working Example

Nov 3, 2026  •  5 min read
WhatsApp API with Node.js: Full Working Example

Node has shipped fetch since version 18, which means sending a WhatsApp message needs zero dependencies. Here's the whole thing, working, before anything else:

js

const API_KEY = process.env.WAHTTP_API_KEY;
const SESSION_ID = process.env.WAHTTP_SESSION_ID;
const BASE = 'https://app.wahttp.com/api';

const res = await fetch(`${BASE}/sessions/${SESSION_ID}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: '+14155551234', type: 'text', body: 'Hello from Node.js' }),
});

if (!res.ok) throw new Error(`Send failed (${res.status}): ${await res.text()}`);
console.log(await res.json()); // { status: 'success', messageId: 'msg_abc123' }

Run it with node send.mjs (or any filename ending in .mjs, or set "type": "module" in your package.json), swap in your own key, session ID and number, and that's a message sent. The res.ok check is the line people skip and regret: fetch doesn't throw on a 4xx or 5xx, it resolves normally with a failure status, so without that check a broken integration looks identical to a working one in your logs.

Before any code: connect a session

Nothing sends until a number is actually connected. Create a session in your WaHttp dashboard, scan the QR code with WhatsApp on the phone you're connecting, and copy the session ID and API key it issues. Those are the two environment variables the snippet above reads. Until the dashboard shows the session as connected, calls will fail with a clear error rather than hanging or doing nothing, so check connection status first if anything above doesn't work.

bash

export WAHTTP_API_KEY="wahttp_live_xxxxxxxxxxxx"
export WAHTTP_SESSION_ID="your_session_id"

Build against a wahttp_test_ key and switch to wahttp_live_ only once you're sending to real recipients.

A small client instead of repeating fetch calls

One call is fine for a script. For anything that sends more than once, wrap it:

js

const BASE = 'https://app.wahttp.com/api';

function client(apiKey, sessionId) {
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};

async function post(payload, timeoutMs = 15000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${BASE}/sessions/${sessionId}/messages`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!res.ok) throw new Error(`WhatsApp send failed (${res.status}): ${await res.text()}`);
return res.json();
} finally {
clearTimeout(timer);
}
}

return {
sendText: (to, body) => post({ to, type: 'text', body }),
sendImage: (to, url, caption = '') => post({ to, type: 'image', url, caption }, 30000),
};
}

const wa = client(process.env.WAHTTP_API_KEY, process.env.WAHTTP_SESSION_ID);
await wa.sendText('+14155551234', 'Your order has shipped.');

fetch has no built-in timeout, so the AbortController pattern above isn't optional decoration, it's what stops a hung request from quietly stalling your process. Media gets a longer timeout than text for the same reason it does in every other language here: the API fetches the file from your URL before delivering it, and that round trip takes longer than a text send.

Receiving messages: the half most tutorials skip

Sending is one direction. Express is the fastest way to stand up the other half.

bash

npm install express

js

import express from 'express';

const app = express();
const wa = client(process.env.WAHTTP_API_KEY, process.env.WAHTTP_SESSION_ID);

app.post('/webhooks/whatsapp', express.json(), async (req, res) => {
res.sendStatus(200); // acknowledge before doing any work

const { event, data } = req.body;
if (event !== 'message.received') return;
if (data.isGroup) return; // don't auto-reply inside groups

await wa.sendText(data.from, `You said: ${data.body}`);
});

app.listen(8000, () => console.log('Listening on :8000'));

Run it, expose port 8000 with a tunnel like ngrok while developing locally, and register that public URL as your session's webhook in the dashboard. The order of operations in the handler matters more than the logic inside it: acknowledge with res.sendStatus(200) first, then do the actual work. If you reply before acknowledging and your downstream logic is slow, often because it calls a language model, the webhook will time out and WhatsApp will redeliver the same event while your first attempt is still running. And always check isGroup before replying, or an echo bot will answer every message in every group it belongs to.

Putting it together: a minimal echo bot

The full loop, send and receive, in one file:

js

import express from 'express';

const BASE = 'https://app.wahttp.com/api';
const API_KEY = process.env.WAHTTP_API_KEY;
const SESSION_ID = process.env.WAHTTP_SESSION_ID;

async function sendText(to, body) {
const res = await fetch(`${BASE}/sessions/${SESSION_ID}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to, type: 'text', body }),
});
if (!res.ok) throw new Error(`Send failed (${res.status}): ${await res.text()}`);
return res.json();
}

const app = express();

app.post('/webhooks/whatsapp', express.json(), async (req, res) => {
res.sendStatus(200);
const { event, data } = req.body;
if (event === 'message.received' && !data.isGroup) {
await sendText(data.from, `Echo: ${data.body}`);
}
});

app.listen(8000, () => console.log('Echo bot running on :8000'));

Message your connected number and it echoes back, no WhatsApp-specific package anywhere in the dependency tree.

The three things that break this in production

This is deliberately the fast version. A few things it skips on purpose, because each deserves more room than a first working example should take:

Phone number formatting. Real input arrives as (415) 555-1234, 415-555-1234 and other shapes the API won't accept as-is. Normalize to E.164 with libphonenumber-js before sending, not after a message silently fails to land.

Rate limiting. Firing a loop at your network's full speed against a customer list is one of the clearest automation signals WhatsApp's detection looks for. Pace sends with randomized delays between them, not a fixed interval.

Retries and backoff. Retry a 429 or 5xx with exponential backoff and respect any Retry-After header. Never retry a 400 or 401, since a malformed payload or a bad key will fail identically every time you resend it.

All three, with full working code, are covered in our combined Node.js and Python guide, which this post deliberately doesn't repeat. If what you need next is webhook signature verification rather than rate limiting specifically, our webhooks deep dive covers that.

What this costs once you're past the trial

Everything above runs on a free trial, capped at 100 messages a day for seven days, no card required. Past that, WaHttp's pricing runs $5 to $35 a month for one to ten sessions with unlimited messages on every tier and no per-message fee layered on top the way Meta's own official Cloud API charges. Our 2026 pricing breakdown has the full comparison against the official platform's rates.

Which endpoint shape you're actually coding against

Everything here assumes a session-based REST API, where a plain-text body can go to anyone at any time, which is why none of this deals with template names or approval states. On Meta's official Cloud API, the same send becomes a template call outside the 24-hour customer service window, with variable arrays instead of a free-text body, a different code path rather than a different vendor wearing the same shape. Settle which one you're actually building against before you write much more code around either.

WhatsApp