Dev tutorial

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.

May 15, 2026 · 12 min read · MercaBot

Prerequisites

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.

  1. Create a GitHub repo with the code.
  2. On render.com: New → Web Service → connect to the repo.
  3. Build command: npm install. Start command: node server.js.
  4. Add the environment variables (WHATSAPP_TOKEN, APP_SECRET, etc.).
  5. Deploy. Grab the public URL (e.g. https://wa-webhook.onrender.com).

Step 7 — Register the webhook in Business Manager

  1. developers.facebook.com → your app → WhatsApp → Configuration.
  2. Under "Webhook", click Configure.
  3. Callback URL: https://wa-webhook.onrender.com/webhook.
  4. Verify Token: the same value as VERIFY_TOKEN in your .env.
  5. Click Verify and Save. Meta sends a GET → your endpoint replies with the challenge → registered.
  6. Under "Webhook fields", click Manage and enable at least messages and message_template_status_update.

Testing

  1. Send a message from your personal phone to the company's WhatsApp number.
  2. Server logs should show: 📩 1415...: hi test.
  3. You should receive back: Got it: "hi test".

Common errors

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 →