Tutorial: WhatsApp webhook in Node.js (complete code)
I'll show you from scratch how to receive WhatsApp messages via Meta Cloud API using Node.js + Express, validate the HMAC signature for security, and process different message types (text, audio, image, status). Code tested in production, ready to copy.
Prerequisites
- Node.js 18+ installed.
- Meta Business account with WABA configured.
- App created at
developers.facebook.comwith the WhatsApp product added. - You have on hand:
WHATSAPP_TOKEN(System User),APP_SECRET,PHONE_NUMBER_ID,VERIFY_TOKEN(you create this). - Server with public HTTPS (we'll use Render in the example — works on Railway, Fly.io, AWS, etc.).
Step 1 — Project setup
mkdir wa-webhook && cd wa-webhook npm init -y npm install express # crypto ships with Node core, no install needed touch server.js .env
File .env:
WHATSAPP_TOKEN=EAAxxxx... APP_SECRET=abc123... PHONE_NUMBER_ID=109876543210987 VERIFY_TOKEN=my_random_secret_token_xyz PORT=3000
Step 2 — Verification endpoint (GET)
When you register the webhook in Business Manager, Meta sends a GET with hub.challenge. Your endpoint needs to return exactly that value back, proving you "own" the endpoint.
// server.js
import express from 'express';
import crypto from 'crypto';
const app = express();
// IMPORTANT: we need the raw body to validate the signature later
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
const { WHATSAPP_TOKEN, APP_SECRET, PHONE_NUMBER_ID, VERIFY_TOKEN, PORT = 3000 } = process.env;
// Initial webhook verification
app.get('/webhook', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
console.log('Webhook verified');
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
Step 3 — Receiving endpoint (POST)
Events land here: new message, delivery status, profile change, etc. Always respond 200 quickly (in <5 seconds), otherwise Meta retries and may ban your webhook.
// Event receiver
app.post('/webhook', (req, res) => {
// 1) Validate signature before anything else
if (!verifySignature(req)) {
console.warn('Invalid signature — rejecting');
return res.sendStatus(401);
}
// 2) Respond 200 right away (process async)
res.sendStatus(200);
// 3) Process the event
processEvent(req.body).catch(err => console.error('Processing error:', err));
});
function verifySignature(req) {
const sig = req.get('x-hub-signature-256');
if (!sig) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', APP_SECRET)
.update(req.rawBody)
.digest('hex');
// crypto.timingSafeEqual prevents timing attacks
try {
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch { return false; }
}
Step 4 — Process the message
Meta's payload has a nested structure. Here's a parser for the 4 most common types:
async function processEvent(body) {
// Payload: { object, entry: [{ changes: [{ value: { ... } }] }] }
const entry = body.entry?.[0];
const change = entry?.changes?.[0]?.value;
if (!change) return;
// a) Incoming message
const msgs = change.messages || [];
for (const msg of msgs) {
const from = msg.from; // sender's number
const id = msg.id;
if (msg.type === 'text') {
const text = msg.text.body;
console.log(`📩 ${from}: ${text}`);
await reply(from, `Got it: "${text}"`);
}
else if (msg.type === 'image' || msg.type === 'audio' || msg.type === 'video' || msg.type === 'document') {
const mediaId = msg[msg.type].id;
console.log(`📷 ${from} sent ${msg.type} id=${mediaId}`);
// To download the file: GET https://graph.facebook.com/v21.0/{mediaId}
// (with Bearer token) → returns a short-TTL URL for download
}
else if (msg.type === 'button' || msg.type === 'interactive') {
// Template button or interactive list clicked
console.log(`🔘 ${from} clicked a button`);
}
}
// b) Delivery status (delivered, read, failed)
const statuses = change.statuses || [];
for (const st of statuses) {
console.log(`✓ status msg=${st.id} status=${st.status}`);
}
}
Step 5 — Send a message back
async function reply(to, text) {
const url = `https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`;
const body = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: { body: text, preview_url: false }
};
const r = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${WHATSAPP_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!r.ok) {
console.error('Send error:', r.status, await r.text());
}
}
app.listen(PORT, () => console.log(`🟢 Webhook listening on port ${PORT}`));
Step 6 — Deploy
Render is the simplest for testing (free tier is enough). Alternatives: Railway, Fly.io, AWS Lambda.
- Create a GitHub repo with the code.
- On
render.com: New → Web Service → connect to the repo. - Build command:
npm install. Start command:node server.js. - Add the environment variables (WHATSAPP_TOKEN, APP_SECRET, etc.).
- Deploy. Grab the public URL (e.g.
https://wa-webhook.onrender.com).
Step 7 — Register the webhook in Business Manager
developers.facebook.com→ your app → WhatsApp → Configuration.- Under "Webhook", click Configure.
- Callback URL:
https://wa-webhook.onrender.com/webhook. - Verify Token: the same value as
VERIFY_TOKENin your .env. - Click Verify and Save. Meta sends a GET → your endpoint replies with the challenge → registered.
- Under "Webhook fields", click Manage and enable at least
messagesandmessage_template_status_update.
Testing
- Send a message from your personal phone to the company's WhatsApp number.
- Server logs should show:
📩 1415...: hi test. - You should receive back:
Got it: "hi test".
Common errors
- 401 on webhook validation: VERIFY_TOKEN in .env doesn't match the one in BM.
- Invalid signature: you forgot to configure
verify: (req, _res, buf) => { req.rawBody = buf }in express.json. - 5s timeout: processed synchronously. Always reply
res.sendStatus(200)immediately and process async. - Message arrives but doesn't send: expired token (default System User token expires in 60 days unless permanent). Generate a permanent token at Business Settings → Users → System Users.
- Error 470 "outside 24h window": tried to send free-form text to a contact who hasn't replied in the last 24h. Use an HSM template.
When it's worth building from scratch (and when it's not)
Building from scratch makes sense if you're a dev building a SaaS with WhatsApp embedded in the product (e.g. delivery app, fintech sending notifications). You need full code control, integration with internal microservices, your own deploy.
It doesn't make sense if you just want to handle customers on your company's WhatsApp — spending 2-4 weeks implementing a multi-agent panel, AI bot, HSM broadcast, and templates from scratch is wasted work. A platform like MercaBot solves it in 3 minutes for $39/month.
WhatsApp support without code
If the goal is to handle customers (not build a product), skip the webhook part. MercaBot connects in 3 minutes with a ready-made panel + AI bot + broadcast.
Try it free →