← Blog

·

How to Build a WhatsApp Bot in Node.js with Express (Official Cloud API, from Scratch)

Updated on October 28, 2026

To build a WhatsApp bot in Node.js on the official API, you need an Express server behind HTTPS with two routes: a GET that returns Meta’s verification challenge and a POST that receives messages, validates the X-Hub-Signature-256 signature over the raw body (using express.raw) and replies through the Cloud API /messages endpoint with native fetch. No SDK and no dependencies besides Express: it all fits in a single file of about 150 lines.

The finished bot answers “hi” or “menu” with three buttons, handles each button tap, marks messages as read, ignores duplicate events and runs in production with Docker and automatic HTTPS. The code was tested with Express 5.3 on Node.js 26 and works directly with Meta or through Vectalk by changing two environment variables. If you prefer Python, the same bot exists as a FastAPI version.

How the bot works

  1. A customer sends a message to your WhatsApp Business number.
  2. Meta sends a POST with the event to your webhook URL.
  3. The server validates the signature, returns 200 right away and processes the event afterwards.
  4. The bot calls POST /<PHONE_NUMBER_ID>/messages to reply.

Because the reply goes out right after the customer’s message, it falls inside the 24-hour customer service window, so it can be free-form text or an interactive message with no template. For an overview of how to get API access, see the WhatsApp Cloud API guide.

What you need first

VariableWhat it isWhere to find it
PHONE_NUMBER_IDThe number’s ID on the Cloud APIApp Dashboard, WhatsApp section
ACCESS_TOKENToken with the whatsapp_business_messaging permissionSystem user token
APP_SECRETApp secret, used for the signatureApp settings > Basic
VERIFY_TOKENAny string you chooseYou set it and repeat it in the dashboard

Use a permanent system user access token, because the temporary dashboard token expires. The number also has to be registered on the Cloud API, with its two-step verification PIN set.

You need Node.js 18 or newer, which is the minimum Express 5 requires and already ships a global fetch, so there is no need for axios.

Want to try it while you read? Create a Vectalk account and send your first message through the official API. Create free account →

1. Set up the project

mkdir whatsapp-bot && cd whatsapp-bot
npm init -y
npm pkg set type=module
npm install express@5

type=module lets you use import. Create server.js. The code blocks in sections 1 to 5 make up the complete file: paste them in order.

import crypto from 'node:crypto';
import express from 'express';

const {
  VERIFY_TOKEN,
  ACCESS_TOKEN,
  APP_SECRET = '',
  WEBHOOK_KEY = '',
  PHONE_NUMBER_ID = '',
  PORT = 3000,
} = process.env;
const API_URL =
  process.env.API_URL ?? `https://graph.facebook.com/v26.0/${PHONE_NUMBER_ID}/messages`;

const app = express();
const processed = new Set(); // in production, use Redis or your database

API_URL defaults to Meta’s Graph API. To use Vectalk, you swap this variable and the token, as shown in the Vectalk section.

2. Webhook verification (GET)

When you save the callback URL in the dashboard, Meta sends a GET with hub.mode=subscribe, hub.verify_token and hub.challenge. If the token matches, return 200 with the hub.challenge value. Any other response leaves the endpoint unverified, and Meta won’t send events.

app.get('/webhook', (req, res) => {
  const mode = req.query['hub.mode'];
  const token = req.query['hub.verify_token'];
  if (mode === 'subscribe' && token === VERIFY_TOKEN) {
    return res.status(200).send(req.query['hub.challenge']);
  }
  res.sendStatus(403);
});

3. Validate the signature with express.raw

Every POST from Meta carries X-Hub-Signature-256: sha256=<hash>, an HMAC-SHA256 of the body keyed with your app secret. The hash must be computed over the exact bytes that arrived. If express.json() has already turned the body into an object, JSON.stringify won’t give you the same bytes back, and the signature will never match.

The fix is to use express.raw on the webhook route only. According to the Express docs, this middleware puts the body in req.body as a Buffer, but only when the Content-Type matches the type option, which defaults to application/octet-stream. Meta sends application/json, so you must pass type: 'application/json'.

function signatureOk(rawBody, header = '') {
  if (!APP_SECRET) return true;
  const expected =
    'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

crypto.timingSafeEqual compares in constant time but throws if the buffers have different lengths, which is why the code checks the length first.

Three common mistakes at this step:

  • A global app.use(express.json()) before the route. The body arrives already parsed and the Buffer is gone. Register express.json() after the webhook route, or only on the routes that need it.
  • Wrong type in express.raw. With the default, req.body is undefined for JSON requests.
  • Wrong key. The signature uses the app secret, not the access token or the VERIFY_TOKEN.

The article on validating the webhook signature goes deeper.

4. Receive the event and acknowledge fast

Meta expects a quick 200. If delivery fails, it retries with decreasing frequency for up to 7 days, so the same event can arrive more than once. The code acknowledges before processing and skips message IDs it has already seen.

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (WEBHOOK_KEY && req.query.key !== WEBHOOK_KEY) return res.sendStatus(403);
  if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
  if (!signatureOk(req.body, req.get('x-hub-signature-256'))) return res.sendStatus(401);

  const payload = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // acknowledge now, process later
  processPayload(payload).catch((err) => console.error('processing failed', err));
});

async function processPayload(payload) {
  for (const entry of payload.entry ?? []) {
    for (const change of entry.changes ?? []) {
      for (const msg of change.value?.messages ?? []) {
        if (processed.has(msg.id)) continue; // Meta may redeliver the same event
        processed.add(msg.id);
        try {
          await handle(msg);
        } catch (err) {
          console.error(`failed to reply to ${msg.id}`, err.message);
        }
      }
    }
  }
}

res.sendStatus(200) goes out before processPayload, which runs without blocking the response. Status events (sent, delivered, read, failed) arrive on the same webhook under value.statuses, and this bot ignores them. To handle them, see WhatsApp API message status webhooks.

5. Reply with text and interactive buttons

The logic lives in handle. “hi” or “menu” opens the menu; a button tap arrives as type: "interactive" with interactive.type: "button_reply" and the id you set when sending.

const ANSWERS = {
  hours: "We're open Monday to Friday, 9 a.m. to 6 p.m.",
  pricing: 'See the full price list at https://example.com/pricing',
  human: 'Sure! An agent will pick up the conversation here.',
};

async function handle(msg) {
  const dest = recipientOf(msg);
  await markRead(msg.id);

  if (msg.type === 'text') {
    const text = msg.text.body.trim().toLowerCase();
    if (['hi', 'hello', 'menu'].includes(text)) return sendMenu(dest);
    return sendText(dest, "Sorry, I didn't get that. Type *menu* to see the options.");
  }
  if (msg.type === 'interactive' && msg.interactive.type === 'button_reply') {
    const choice = msg.interactive.button_reply.id;
    return sendText(dest, ANSWERS[choice] ?? 'Invalid option.');
  }
  return sendText(dest, 'For now I only understand text and buttons.');
}

function recipientOf(msg) {
  // Users with a username may arrive without "from", only with their BSUID
  return msg.from ? { to: msg.from } : { recipient: msg.from_user_id };
}

recipientOf gets the bot ready for WhatsApp usernames. According to Meta’s documentation, when a user adopts a username the from field may be missing from the webhook, but from_user_id (the business-scoped user ID, or BSUID) is always there, and since July 2026 you can send messages to it with the recipient field. Details in usernames and BSUIDs.

And the functions that call the API with native fetch:

async function callApi(payload) {
  const r = await fetch(API_URL, {
    method: 'POST',
    headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
  });
  const body = await r.json();
  if (!r.ok) throw new Error(`API ${r.status}: ${JSON.stringify(body)}`);
  return body;
}

async function markRead(messageId) {
  try {
    await callApi({ status: 'read', message_id: messageId, typing_indicator: { type: 'text' } });
  } catch {
    // never blocks the reply
  }
}

function sendText(dest, text) {
  return callApi({ recipient_type: 'individual', ...dest, type: 'text', text: { body: text } });
}

function sendMenu(dest) {
  const buttons = [
    ['hours', 'Opening hours'],
    ['pricing', 'Pricing'],
    ['human', 'Talk to an agent'],
  ];
  return callApi({
    recipient_type: 'individual',
    ...dest,
    type: 'interactive',
    interactive: {
      type: 'button',
      body: { text: 'Hi! How can I help?' },
      action: {
        buttons: buttons.map(([id, title]) => ({ type: 'reply', reply: { id, title } })),
      },
    },
  });
}

app.listen(PORT, () => console.log(`Bot listening on port ${PORT}`));

markRead marks the message as read and shows a typing indicator; Meta removes it when you reply or after 25 seconds. One fetch gotcha: it doesn’t throw on 4xx and 5xx responses, so the code checks r.ok and throws with Meta’s error body, which includes the error code you need for debugging.

Reply button limits, according to Meta’s documentation:

FieldLimit
Buttons per messageup to 3
Button title20 characters
Button id256 characters
body.text1,024 characters
footer.text60 characters

A button tap arrives on the webhook like this (excerpt of messages[0]):

{
  "from": "15551234567",
  "id": "wamid.HBgL...",
  "type": "interactive",
  "interactive": {
    "type": "button_reply",
    "button_reply": { "id": "pricing", "title": "Pricing" }
  }
}

6. Test locally

export VERIFY_TOKEN="any-string-you-like"
export ACCESS_TOKEN="EAAG..."
export APP_SECRET="your-app-secret"
export PHONE_NUMBER_ID="123456789012345"
node server.js

In another terminal, test verification and a signed event without involving Meta:

curl "http://127.0.0.1:3000/webhook?hub.mode=subscribe&hub.verify_token=any-string-you-like&hub.challenge=42"

BODY='{"entry":[{"changes":[{"value":{"messages":[{"from":"15551234567","id":"wamid.test1","type":"text","text":{"body":"menu"}}]}}]}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $NF}')
curl -i -X POST http://127.0.0.1:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  --data-binary "$BODY"

The first command prints 42. The second returns 200 OK and makes the bot send the menu to the number in from (use your own test number). Change one character of BODY without recomputing the signature and the response becomes 401.

For real events, Meta requires a public URL with a valid TLS certificate. During development, use a tunnel, as shown in testing the WhatsApp webhook locally with ngrok. Then go to App Dashboard > WhatsApp > Configuration, fill in the callback URL (https://.../webhook) and the verify token, save and subscribe to the messages field.

7. Simple deployment with Docker and HTTPS

npm install already created package-lock.json. Create the Dockerfile:

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js .
ENV PORT=3000
CMD ["node", "server.js"]

Run the container on your server:

docker build -t whatsapp-bot .
docker run -d --restart unless-stopped -p 127.0.0.1:3000:3000 \
  -e VERIFY_TOKEN -e ACCESS_TOKEN -e APP_SECRET -e PHONE_NUMBER_ID \
  whatsapp-bot

For HTTPS, Caddy obtains and renews the certificate on its own when the domain’s DNS points to the server and ports 80 and 443 are open. The Caddyfile:

bot.yourcompany.com
reverse_proxy 127.0.0.1:3000

Update the callback URL in the dashboard to https://bot.yourcompany.com/webhook.

Production checklist

  • Persistent deduplication. The in-memory Set disappears on every restart and isn’t shared across instances. Store IDs in Redis or your database with an expiry.
  • A queue. If replies depend on an LLM or slow systems, move processing to a queue (BullMQ, RabbitMQ, SQS) with a separate worker. A process that crashes mid-processPayload loses the event, because the 200 was already sent.
  • One instance for now. With in-memory state, don’t run in cluster mode. Move state out of the process before scaling.
  • API errors. The code logs Meta’s error body; the WhatsApp API error codes reference helps you read it.
  • Secrets out of the code. Keep the token and app secret in environment variables or a secrets manager.
  • Media. Images, audio and documents arrive on the webhook with an ID only; downloading them is a separate call to the media API.

Doing it with Vectalk

Vectalk is a proxy for Meta’s official WhatsApp Cloud API: send payloads and webhook events are the same as Meta’s, so server.js works without logic changes. Three things change:

  1. URL and token. Set API_URL=https://apis.vectalk.com.br/api/messages and use a Vectalk instance token as ACCESS_TOKEN. With it, the phone_number_id is resolved automatically.
  2. Webhook registration via API. Register the URL with POST https://apis.vectalk.com.br/api/webhooks, passing instance_id, url and name. You can have several URLs per instance.
  3. Endpoint protection. Vectalk’s webhook documentation doesn’t describe a signature header. Leave APP_SECRET empty, set WEBHOOK_KEY to a long random value and register the URL as https://bot.yourcompany.com/webhook?key=YOUR_VALUE.
const r = await fetch('https://apis.vectalk.com.br/api/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.VECTALK_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    instance_id: 'YOUR_INSTANCE',
    url: 'https://bot.yourcompany.com/webhook?key=YOUR_VALUE',
    name: 'Node.js bot',
  }),
});
console.log(await r.json());

Vectalk expects a 200 within 20 seconds and resends the event if it doesn’t get one, which deduplication already covers. Create your account at apps.vectalk.com.br/register and see the other endpoints in the documentation. To add AI to the replies, the tutorial on connecting ChatGPT to the official WhatsApp API starts from a similar base.

FAQ

Do I need a WhatsApp library for Node.js?

No. The Cloud API is plain HTTP and JSON: Express to receive and fetch to send are enough. Libraries that automate WhatsApp Web, such as Baileys, are a different thing: they don’t use Meta’s official API.

Why does the signature never match?

It’s almost always the body: some middleware parsed the JSON before express.raw, or the type isn’t application/json. Also check that the key is the app secret.

Can the bot start the conversation?

Free-form text and buttons only work inside the 24-hour window opened by the customer’s last message. Outside it, you must send an approved message template.

How much does it cost to run a bot?

Replies inside the customer service window are service messages. Since October 1, 2026, each number gets 1,000 free service messages per month; from the 1,001st on, the rate for recipients in Brazil is R$ 0.0350 (Brazilian reais) per message. Rates for other countries are on Meta’s pricing page.

Can I use TypeScript or Fastify?

Yes. The sequence doesn’t change: answer the verification GET, validate the HMAC over the raw body, return 200 fast and call the messages endpoint. With Fastify, make sure you can access the raw body before the JSON is parsed.


Official sources checked on October 28, 2026: Create a webhook endpoint (Meta), Webhooks overview (Meta), Interactive reply buttons messages (Meta), Interactive messages webhook (Meta), Typing indicators (Meta), Business-scoped user IDs (Meta), Pricing (Meta), Express 5: express.raw(), Express 5.x API, Caddy: reverse proxy quick-start, Vectalk: webhooks, Vectalk: authentication, Vectalk: reply buttons.

Try the official WhatsApp API today

Create your account, generate a token and send your first message with the quick-start guide. Same payloads and webhooks as Meta's Cloud API.