{"id":541,"date":"2026-10-28T16:07:09","date_gmt":"2026-10-28T16:07:09","guid":{"rendered":"https:\/\/vectalk.com.br\/blog\/whatsapp-bot-nodejs-express\/"},"modified":"2026-10-28T16:07:13","modified_gmt":"2026-10-28T16:07:13","slug":"whatsapp-bot-nodejs-express","status":"publish","type":"post","link":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/","title":{"rendered":"How to Build a WhatsApp Bot in Node.js with Express (Official Cloud API, from Scratch)"},"content":{"rendered":"\n<p><em>Updated on October 28, 2026<\/em><\/p>\n\n\n\n<p>To build a WhatsApp bot in Node.js on the official API, you need an Express server behind HTTPS with two routes: a <code>GET<\/code> that returns Meta&#8217;s verification challenge and a <code>POST<\/code> that receives messages, validates the <code>X-Hub-Signature-256<\/code> signature over the raw body (using <code>express.raw<\/code>) and replies through the Cloud API <code>\/messages<\/code> endpoint with native <code>fetch<\/code>. No SDK and no dependencies besides Express: it all fits in a single file of about 150 lines.<\/p>\n\n\n\n<p>The finished bot answers &#8220;hi&#8221; or &#8220;menu&#8221; 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 <a href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-python-fastapi\/\">FastAPI version<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How the bot works<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>A customer sends a message to your WhatsApp Business number.<\/li>\n\n\n<li>Meta sends a <code>POST<\/code> with the event to your webhook URL.<\/li>\n\n\n<li>The server validates the signature, returns <code>200<\/code> right away and processes the event afterwards.<\/li>\n\n\n<li>The bot calls <code>POST \/&lt;PHONE_NUMBER_ID&gt;\/messages<\/code> to reply.<\/li>\n\n<\/ol>\n\n\n\n<p>Because the reply goes out right after the customer&#8217;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 <a href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-cloud-api-en\/\">WhatsApp Cloud API guide<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What you need first<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Variable<\/th><th>What it is<\/th><th>Where to find it<\/th><\/tr><\/thead><tbody><tr><td><code>PHONE_NUMBER_ID<\/code><\/td><td>The number&#8217;s ID on the Cloud API<\/td><td>App Dashboard, WhatsApp section<\/td><\/tr><tr><td><code>ACCESS_TOKEN<\/code><\/td><td>Token with the <code>whatsapp_business_messaging<\/code> permission<\/td><td>System user token<\/td><\/tr><tr><td><code>APP_SECRET<\/code><\/td><td>App secret, used for the signature<\/td><td>App settings &gt; Basic<\/td><\/tr><tr><td><code>VERIFY_TOKEN<\/code><\/td><td>Any string you choose<\/td><td>You set it and repeat it in the dashboard<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>Use a <a href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-api-permanent-access-token\/\">permanent system user access token<\/a>, because the temporary dashboard token expires. The number also has to be registered on the Cloud API, with its two-step verification PIN set.<\/p>\n\n\n\n<p>You need Node.js 18 or newer, which is the minimum Express 5 requires and already ships a global <code>fetch<\/code>, so there is no need for axios.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. Set up the project<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">mkdir whatsapp-bot &amp;&amp; cd whatsapp-bot\nnpm init -y\nnpm pkg set type=module\nnpm install express@5<\/code><\/pre>\n\n\n\n<p><code>type=module<\/code> lets you use <code>import<\/code>. Create <code>server.js<\/code>. The code blocks in sections 1 to 5 make up the complete file: paste them in order.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">import crypto from 'node:crypto';\nimport express from 'express';\n\nconst {\n  VERIFY_TOKEN,\n  ACCESS_TOKEN,\n  APP_SECRET = '',\n  WEBHOOK_KEY = '',\n  PHONE_NUMBER_ID = '',\n  PORT = 3000,\n} = process.env;\nconst API_URL =\n  process.env.API_URL ?? `https:\/\/graph.facebook.com\/v26.0\/${PHONE_NUMBER_ID}\/messages`;\n\nconst app = express();\nconst processed = new Set(); \/\/ in production, use Redis or your database<\/code><\/pre>\n\n\n\n<p><code>API_URL<\/code> defaults to Meta&#8217;s Graph API. To use Vectalk, you swap this variable and the token, as shown in the Vectalk section.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. Webhook verification (GET)<\/h2>\n\n\n\n<p>When you save the callback URL in the dashboard, Meta sends a <code>GET<\/code> with <code>hub.mode=subscribe<\/code>, <code>hub.verify_token<\/code> and <code>hub.challenge<\/code>. If the token matches, return <code>200<\/code> with the <code>hub.challenge<\/code> value. Any other response leaves the endpoint unverified, and Meta won&#8217;t send events.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">app.get('\/webhook', (req, res) =&gt; {\n  const mode = req.query['hub.mode'];\n  const token = req.query['hub.verify_token'];\n  if (mode === 'subscribe' &amp;&amp; token === VERIFY_TOKEN) {\n    return res.status(200).send(req.query['hub.challenge']);\n  }\n  res.sendStatus(403);\n});<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">3. Validate the signature with express.raw<\/h2>\n\n\n\n<p>Every <code>POST<\/code> from Meta carries <code>X-Hub-Signature-256: sha256=&lt;hash&gt;<\/code>, an HMAC-SHA256 of the body keyed with your app secret. The hash must be computed over the exact bytes that arrived. If <code>express.json()<\/code> has already turned the body into an object, <code>JSON.stringify<\/code> won&#8217;t give you the same bytes back, and the signature will never match.<\/p>\n\n\n\n<p>The fix is to use <code>express.raw<\/code> on the webhook route only. According to the Express docs, this middleware puts the body in <code>req.body<\/code> as a <code>Buffer<\/code>, but only when the <code>Content-Type<\/code> matches the <code>type<\/code> option, which defaults to <code>application\/octet-stream<\/code>. Meta sends <code>application\/json<\/code>, so you must pass <code>type: 'application\/json'<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">function signatureOk(rawBody, header = '') {\n  if (!APP_SECRET) return true;\n  const expected =\n    'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');\n  const a = Buffer.from(expected);\n  const b = Buffer.from(header);\n  return a.length === b.length &amp;&amp; crypto.timingSafeEqual(a, b);\n}<\/code><\/pre>\n\n\n\n<p><code>crypto.timingSafeEqual<\/code> compares in constant time but throws if the buffers have different lengths, which is why the code checks the length first.<\/p>\n\n\n\n<p>Three common mistakes at this step:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>A global <code>app.use(express.json())<\/code> before the route.<\/strong> The body arrives already parsed and the <code>Buffer<\/code> is gone. Register <code>express.json()<\/code> after the webhook route, or only on the routes that need it.<\/li>\n\n\n<li><strong>Wrong <code>type<\/code> in <code>express.raw<\/code>.<\/strong> With the default, <code>req.body<\/code> is <code>undefined<\/code> for JSON requests.<\/li>\n\n\n<li><strong>Wrong key.<\/strong> The signature uses the app secret, not the access token or the <code>VERIFY_TOKEN<\/code>.<\/li>\n\n<\/ul>\n\n\n\n<p>The article on <a href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-webhook-signature-validation\/\">validating the webhook signature<\/a> goes deeper.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Receive the event and acknowledge fast<\/h2>\n\n\n\n<p>Meta expects a quick <code>200<\/code>. 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">app.post('\/webhook', express.raw({ type: 'application\/json' }), (req, res) =&gt; {\n  if (WEBHOOK_KEY &amp;&amp; req.query.key !== WEBHOOK_KEY) return res.sendStatus(403);\n  if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);\n  if (!signatureOk(req.body, req.get('x-hub-signature-256'))) return res.sendStatus(401);\n\n  const payload = JSON.parse(req.body.toString('utf8'));\n  res.sendStatus(200); \/\/ acknowledge now, process later\n  processPayload(payload).catch((err) =&gt; console.error('processing failed', err));\n});\n\nasync function processPayload(payload) {\n  for (const entry of payload.entry ?? []) {\n    for (const change of entry.changes ?? []) {\n      for (const msg of change.value?.messages ?? []) {\n        if (processed.has(msg.id)) continue; \/\/ Meta may redeliver the same event\n        processed.add(msg.id);\n        try {\n          await handle(msg);\n        } catch (err) {\n          console.error(`failed to reply to ${msg.id}`, err.message);\n        }\n      }\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p><code>res.sendStatus(200)<\/code> goes out before <code>processPayload<\/code>, which runs without blocking the response. Status events (<code>sent<\/code>, <code>delivered<\/code>, <code>read<\/code>, <code>failed<\/code>) arrive on the same webhook under <code>value.statuses<\/code>, and this bot ignores them. To handle them, see <a href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-api-message-status-webhook\/\">WhatsApp API message status webhooks<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. Reply with text and interactive buttons<\/h2>\n\n\n\n<p>The logic lives in <code>handle<\/code>. &#8220;hi&#8221; or &#8220;menu&#8221; opens the menu; a button tap arrives as <code>type: \"interactive\"<\/code> with <code>interactive.type: \"button_reply\"<\/code> and the <code>id<\/code> you set when sending.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">const ANSWERS = {\n  hours: \"We're open Monday to Friday, 9 a.m. to 6 p.m.\",\n  pricing: 'See the full price list at https:\/\/example.com\/pricing',\n  human: 'Sure! An agent will pick up the conversation here.',\n};\n\nasync function handle(msg) {\n  const dest = recipientOf(msg);\n  await markRead(msg.id);\n\n  if (msg.type === 'text') {\n    const text = msg.text.body.trim().toLowerCase();\n    if (['hi', 'hello', 'menu'].includes(text)) return sendMenu(dest);\n    return sendText(dest, \"Sorry, I didn't get that. Type *menu* to see the options.\");\n  }\n  if (msg.type === 'interactive' &amp;&amp; msg.interactive.type === 'button_reply') {\n    const choice = msg.interactive.button_reply.id;\n    return sendText(dest, ANSWERS[choice] ?? 'Invalid option.');\n  }\n  return sendText(dest, 'For now I only understand text and buttons.');\n}\n\nfunction recipientOf(msg) {\n  \/\/ Users with a username may arrive without \"from\", only with their BSUID\n  return msg.from ? { to: msg.from } : { recipient: msg.from_user_id };\n}<\/code><\/pre>\n\n\n\n<p><code>recipientOf<\/code> gets the bot ready for WhatsApp usernames. According to Meta&#8217;s documentation, when a user adopts a username the <code>from<\/code> field may be missing from the webhook, but <code>from_user_id<\/code> (the business-scoped user ID, or BSUID) is always there, and since July 2026 you can send messages to it with the <code>recipient<\/code> field. Details in <a href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-usernames-bsuid\/\">usernames and BSUIDs<\/a>.<\/p>\n\n\n\n<p>And the functions that call the API with native <code>fetch<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">async function callApi(payload) {\n  const r = await fetch(API_URL, {\n    method: 'POST',\n    headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, 'Content-Type': 'application\/json' },\n    body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),\n  });\n  const body = await r.json();\n  if (!r.ok) throw new Error(`API ${r.status}: ${JSON.stringify(body)}`);\n  return body;\n}\n\nasync function markRead(messageId) {\n  try {\n    await callApi({ status: 'read', message_id: messageId, typing_indicator: { type: 'text' } });\n  } catch {\n    \/\/ never blocks the reply\n  }\n}\n\nfunction sendText(dest, text) {\n  return callApi({ recipient_type: 'individual', ...dest, type: 'text', text: { body: text } });\n}\n\nfunction sendMenu(dest) {\n  const buttons = [\n    ['hours', 'Opening hours'],\n    ['pricing', 'Pricing'],\n    ['human', 'Talk to an agent'],\n  ];\n  return callApi({\n    recipient_type: 'individual',\n    ...dest,\n    type: 'interactive',\n    interactive: {\n      type: 'button',\n      body: { text: 'Hi! How can I help?' },\n      action: {\n        buttons: buttons.map(([id, title]) =&gt; ({ type: 'reply', reply: { id, title } })),\n      },\n    },\n  });\n}\n\napp.listen(PORT, () =&gt; console.log(`Bot listening on port ${PORT}`));<\/code><\/pre>\n\n\n\n<p><code>markRead<\/code> marks the message as read and shows a typing indicator; Meta removes it when you reply or after 25 seconds. One <code>fetch<\/code> gotcha: it doesn&#8217;t throw on 4xx and 5xx responses, so the code checks <code>r.ok<\/code> and throws with Meta&#8217;s error body, which includes the error code you need for debugging.<\/p>\n\n\n\n<p>Reply button limits, according to Meta&#8217;s documentation:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Field<\/th><th>Limit<\/th><\/tr><\/thead><tbody><tr><td>Buttons per message<\/td><td>up to 3<\/td><\/tr><tr><td>Button <code>title<\/code><\/td><td>20 characters<\/td><\/tr><tr><td>Button <code>id<\/code><\/td><td>256 characters<\/td><\/tr><tr><td><code>body.text<\/code><\/td><td>1,024 characters<\/td><\/tr><tr><td><code>footer.text<\/code><\/td><td>60 characters<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>A button tap arrives on the webhook like this (excerpt of <code>messages[0]<\/code>):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-json\">{\n  \"from\": \"15551234567\",\n  \"id\": \"wamid.HBgL...\",\n  \"type\": \"interactive\",\n  \"interactive\": {\n    \"type\": \"button_reply\",\n    \"button_reply\": { \"id\": \"pricing\", \"title\": \"Pricing\" }\n  }\n}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">6. Test locally<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">export VERIFY_TOKEN=\"any-string-you-like\"\nexport ACCESS_TOKEN=\"EAAG...\"\nexport APP_SECRET=\"your-app-secret\"\nexport PHONE_NUMBER_ID=\"123456789012345\"\nnode server.js<\/code><\/pre>\n\n\n\n<p>In another terminal, test verification and a signed event without involving Meta:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">curl \"http:\/\/127.0.0.1:3000\/webhook?hub.mode=subscribe&amp;hub.verify_token=any-string-you-like&amp;hub.challenge=42\"\n\nBODY='{\"entry\":[{\"changes\":[{\"value\":{\"messages\":[{\"from\":\"15551234567\",\"id\":\"wamid.test1\",\"type\":\"text\",\"text\":{\"body\":\"menu\"}}]}}]}]}'\nSIG=$(printf '%s' \"$BODY\" | openssl dgst -sha256 -hmac \"$APP_SECRET\" | awk '{print $NF}')\ncurl -i -X POST http:\/\/127.0.0.1:3000\/webhook \\\n  -H \"Content-Type: application\/json\" \\\n  -H \"X-Hub-Signature-256: sha256=$SIG\" \\\n  --data-binary \"$BODY\"<\/code><\/pre>\n\n\n\n<p>The first command prints <code>42<\/code>. The second returns <code>200 OK<\/code> and makes the bot send the menu to the number in <code>from<\/code> (use your own test number). Change one character of <code>BODY<\/code> without recomputing the signature and the response becomes <code>401<\/code>.<\/p>\n\n\n\n<p>For real events, Meta requires a public URL with a valid TLS certificate. During development, use a tunnel, as shown in <a href=\"https:\/\/vectalk.com.br\/blog\/en\/test-whatsapp-webhook-locally\/\">testing the WhatsApp webhook locally with ngrok<\/a>. Then go to App Dashboard &gt; WhatsApp &gt; Configuration, fill in the callback URL (<code>https:\/\/...\/webhook<\/code>) and the verify token, save and subscribe to the <code>messages<\/code> field.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">7. Simple deployment with Docker and HTTPS<\/h2>\n\n\n\n<p><code>npm install<\/code> already created <code>package-lock.json<\/code>. Create the <code>Dockerfile<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">FROM node:22-slim\nWORKDIR \/app\nCOPY package.json package-lock.json .\/\nRUN npm ci --omit=dev\nCOPY server.js .\nENV PORT=3000\nCMD [\"node\", \"server.js\"]<\/code><\/pre>\n\n\n\n<p>Run the container on your server:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">docker build -t whatsapp-bot .\ndocker run -d --restart unless-stopped -p 127.0.0.1:3000:3000 \\\n  -e VERIFY_TOKEN -e ACCESS_TOKEN -e APP_SECRET -e PHONE_NUMBER_ID \\\n  whatsapp-bot<\/code><\/pre>\n\n\n\n<p>For HTTPS, Caddy obtains and renews the certificate on its own when the domain&#8217;s DNS points to the server and ports 80 and 443 are open. The <code>Caddyfile<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">bot.yourcompany.com\nreverse_proxy 127.0.0.1:3000<\/code><\/pre>\n\n\n\n<p>Update the callback URL in the dashboard to <code>https:\/\/bot.yourcompany.com\/webhook<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Production checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Persistent deduplication.<\/strong> The in-memory <code>Set<\/code> disappears on every restart and isn&#8217;t shared across instances. Store IDs in Redis or your database with an expiry.<\/li>\n\n\n<li><strong>A queue.<\/strong> 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-<code>processPayload<\/code> loses the event, because the <code>200<\/code> was already sent.<\/li>\n\n\n<li><strong>One instance for now.<\/strong> With in-memory state, don&#8217;t run in cluster mode. Move state out of the process before scaling.<\/li>\n\n\n<li><strong>API errors.<\/strong> The code logs Meta&#8217;s error body; the <a href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-api-error-codes\/\">WhatsApp API error codes<\/a> reference helps you read it.<\/li>\n\n\n<li><strong>Secrets out of the code.<\/strong> Keep the token and app secret in environment variables or a secrets manager.<\/li>\n\n\n<li><strong>Media.<\/strong> Images, audio and documents arrive on the webhook with an ID only; downloading them is a separate call to the media API.<\/li>\n\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Doing it with Vectalk<\/h2>\n\n\n\n<p>Vectalk is a proxy for Meta&#8217;s official WhatsApp Cloud API: send payloads and webhook events are the same as Meta&#8217;s, so <code>server.js<\/code> works without logic changes. Three things change:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>URL and token.<\/strong> Set <code>API_URL=https:\/\/apis.vectalk.com.br\/api\/messages<\/code> and use a Vectalk instance token as <code>ACCESS_TOKEN<\/code>. With it, the <code>phone_number_id<\/code> is resolved automatically.<\/li>\n\n\n<li><strong>Webhook registration via API.<\/strong> Register the URL with <code>POST https:\/\/apis.vectalk.com.br\/api\/webhooks<\/code>, passing <code>instance_id<\/code>, <code>url<\/code> and <code>name<\/code>. You can have several URLs per instance.<\/li>\n\n\n<li><strong>Endpoint protection.<\/strong> <a href=\"https:\/\/docs.vectalk.com.br\/concepts\/webhooks\">Vectalk&#8217;s webhook documentation<\/a> doesn&#8217;t describe a signature header. Leave <code>APP_SECRET<\/code> empty, set <code>WEBHOOK_KEY<\/code> to a long random value and register the URL as <code>https:\/\/bot.yourcompany.com\/webhook?key=YOUR_VALUE<\/code>.<\/li>\n\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">const r = await fetch('https:\/\/apis.vectalk.com.br\/api\/webhooks', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.VECTALK_TOKEN}`,\n    'Content-Type': 'application\/json',\n  },\n  body: JSON.stringify({\n    instance_id: 'YOUR_INSTANCE',\n    url: 'https:\/\/bot.yourcompany.com\/webhook?key=YOUR_VALUE',\n    name: 'Node.js bot',\n  }),\n});\nconsole.log(await r.json());<\/code><\/pre>\n\n\n\n<p>Vectalk expects a <code>200<\/code> within 20 seconds and resends the event if it doesn&#8217;t get one, which deduplication already covers. Create your account at <a href=\"https:\/\/apps.vectalk.com.br\/register\">apps.vectalk.com.br\/register<\/a> and see the other endpoints in the <a href=\"https:\/\/docs.vectalk.com.br\">documentation<\/a>. To add AI to the replies, the tutorial on <a href=\"https:\/\/vectalk.com.br\/blog\/en\/chatgpt-whatsapp-business-api\/\">connecting ChatGPT to the official WhatsApp API<\/a> starts from a similar base.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">FAQ<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need a WhatsApp library for Node.js?<\/h3>\n\n\n\n<p>No. The Cloud API is plain HTTP and JSON: Express to receive and <code>fetch<\/code> to send are enough. Libraries that automate WhatsApp Web, such as Baileys, are a different thing: they don&#8217;t use Meta&#8217;s official API.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does the signature never match?<\/h3>\n\n\n\n<p>It&#8217;s almost always the body: some middleware parsed the JSON before <code>express.raw<\/code>, or the <code>type<\/code> isn&#8217;t <code>application\/json<\/code>. Also check that the key is the app secret.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can the bot start the conversation?<\/h3>\n\n\n\n<p>Free-form text and buttons only work inside the 24-hour window opened by the customer&#8217;s last message. Outside it, you must send an approved message template.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How much does it cost to run a bot?<\/h3>\n\n\n\n<p>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&#8217;s pricing page.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use TypeScript or Fastify?<\/h3>\n\n\n\n<p>Yes. The sequence doesn&#8217;t change: answer the verification <code>GET<\/code>, validate the HMAC over the raw body, return <code>200<\/code> fast and call the messages endpoint. With Fastify, make sure you can access the raw body before the JSON is parsed.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><strong>Official sources checked on October 28, 2026:<\/strong> <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/webhooks\/create-webhook-endpoint\" target=\"_blank\" rel=\"noreferrer noopener\">Create a webhook endpoint (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/webhooks\/overview\" target=\"_blank\" rel=\"noreferrer noopener\">Webhooks overview (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/messages\/interactive-reply-buttons-messages\" target=\"_blank\" rel=\"noreferrer noopener\">Interactive reply buttons messages (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/webhooks\/reference\/messages\/interactive\" target=\"_blank\" rel=\"noreferrer noopener\">Interactive messages webhook (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/typing-indicators\" target=\"_blank\" rel=\"noreferrer noopener\">Typing indicators (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/business-scoped-user-ids\" target=\"_blank\" rel=\"noreferrer noopener\">Business-scoped user IDs (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/pricing\" target=\"_blank\" rel=\"noreferrer noopener\">Pricing (Meta)<\/a>, <a href=\"https:\/\/expressjs.com\/en\/5x\/api\/express\/\" target=\"_blank\" rel=\"noreferrer noopener\">Express 5: express.raw()<\/a>, <a href=\"https:\/\/expressjs.com\/en\/5x\/api.html\" target=\"_blank\" rel=\"noreferrer noopener\">Express 5.x API<\/a>, <a href=\"https:\/\/caddyserver.com\/docs\/quick-starts\/reverse-proxy\" target=\"_blank\" rel=\"noreferrer noopener\">Caddy: reverse proxy quick-start<\/a>, <a href=\"https:\/\/docs.vectalk.com.br\/concepts\/webhooks\">Vectalk: webhooks<\/a>, <a href=\"https:\/\/docs.vectalk.com.br\/authentication\">Vectalk: authentication<\/a>, <a href=\"https:\/\/docs.vectalk.com.br\/api-reference\/messages\/send-buttons\">Vectalk: reply buttons<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A complete tutorial to build a WhatsApp bot in Node.js with Express on the official Cloud API: webhook, express.raw signature check, buttons and HTTPS deploy.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[],"class_list":["post-541","post","type-post","status-publish","format-standard","hentry","category-blog"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.7 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>WhatsApp API Node.js Bot with Express: Tutorial | Vectalk<\/title>\n<meta name=\"description\" content=\"WhatsApp API Node JS tutorial with Express: webhook verification, signature check with express.raw, native fetch, interactive buttons and deployment.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"WhatsApp API Node.js Bot with Express: Tutorial | Vectalk\" \/>\n<meta property=\"og:description\" content=\"WhatsApp API Node JS tutorial with Express: webhook verification, signature check with express.raw, native fetch, interactive buttons and deployment.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/\" \/>\n<meta property=\"og:site_name\" content=\"Blog Vectalk\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-28T16:07:09+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-10-28T16:07:13+00:00\" \/>\n<meta name=\"author\" content=\"wanderleyfcunha\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"wanderleyfcunha\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"12 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/\"},\"author\":{\"name\":\"wanderleyfcunha\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#\\\/schema\\\/person\\\/ff36ecd9d8257856f227f8e5d639db2b\"},\"headline\":\"How to Build a WhatsApp Bot in Node.js with Express (Official Cloud API, from Scratch)\",\"datePublished\":\"2026-10-28T16:07:09+00:00\",\"dateModified\":\"2026-10-28T16:07:13+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/\"},\"wordCount\":1557,\"articleSection\":[\"Blog\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/\",\"url\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/\",\"name\":\"WhatsApp API Node.js Bot with Express: Tutorial | Vectalk\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#website\"},\"datePublished\":\"2026-10-28T16:07:09+00:00\",\"dateModified\":\"2026-10-28T16:07:13+00:00\",\"author\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#\\\/schema\\\/person\\\/ff36ecd9d8257856f227f8e5d639db2b\"},\"description\":\"WhatsApp API Node JS tutorial with Express: webhook verification, signature check with express.raw, native fetch, interactive buttons and deployment.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/en\\\/whatsapp-bot-nodejs-express\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"How to Build a WhatsApp Bot in Node.js with Express (Official Cloud API, from Scratch)\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/\",\"name\":\"Blog Vectalk\",\"description\":\"\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#\\\/schema\\\/person\\\/ff36ecd9d8257856f227f8e5d639db2b\",\"name\":\"wanderleyfcunha\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g\",\"caption\":\"wanderleyfcunha\"},\"sameAs\":[\"https:\\\/\\\/vectalk.com.br\\\/blog\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"WhatsApp API Node.js Bot with Express: Tutorial | Vectalk","description":"WhatsApp API Node JS tutorial with Express: webhook verification, signature check with express.raw, native fetch, interactive buttons and deployment.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/","og_locale":"en_US","og_type":"article","og_title":"WhatsApp API Node.js Bot with Express: Tutorial | Vectalk","og_description":"WhatsApp API Node JS tutorial with Express: webhook verification, signature check with express.raw, native fetch, interactive buttons and deployment.","og_url":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/","og_site_name":"Blog Vectalk","article_published_time":"2026-10-28T16:07:09+00:00","article_modified_time":"2026-10-28T16:07:13+00:00","author":"wanderleyfcunha","twitter_card":"summary_large_image","twitter_misc":{"Written by":"wanderleyfcunha","Est. reading time":"12 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/#article","isPartOf":{"@id":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/"},"author":{"name":"wanderleyfcunha","@id":"https:\/\/vectalk.com.br\/blog\/#\/schema\/person\/ff36ecd9d8257856f227f8e5d639db2b"},"headline":"How to Build a WhatsApp Bot in Node.js with Express (Official Cloud API, from Scratch)","datePublished":"2026-10-28T16:07:09+00:00","dateModified":"2026-10-28T16:07:13+00:00","mainEntityOfPage":{"@id":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/"},"wordCount":1557,"articleSection":["Blog"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/","url":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/","name":"WhatsApp API Node.js Bot with Express: Tutorial | Vectalk","isPartOf":{"@id":"https:\/\/vectalk.com.br\/blog\/#website"},"datePublished":"2026-10-28T16:07:09+00:00","dateModified":"2026-10-28T16:07:13+00:00","author":{"@id":"https:\/\/vectalk.com.br\/blog\/#\/schema\/person\/ff36ecd9d8257856f227f8e5d639db2b"},"description":"WhatsApp API Node JS tutorial with Express: webhook verification, signature check with express.raw, native fetch, interactive buttons and deployment.","breadcrumb":{"@id":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/vectalk.com.br\/blog\/en\/whatsapp-bot-nodejs-express\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/vectalk.com.br\/blog\/"},{"@type":"ListItem","position":2,"name":"How to Build a WhatsApp Bot in Node.js with Express (Official Cloud API, from Scratch)"}]},{"@type":"WebSite","@id":"https:\/\/vectalk.com.br\/blog\/#website","url":"https:\/\/vectalk.com.br\/blog\/","name":"Blog Vectalk","description":"","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/vectalk.com.br\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Person","@id":"https:\/\/vectalk.com.br\/blog\/#\/schema\/person\/ff36ecd9d8257856f227f8e5d639db2b","name":"wanderleyfcunha","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g","caption":"wanderleyfcunha"},"sameAs":["https:\/\/vectalk.com.br\/blog"]}]}},"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"","_links":{"self":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts\/541","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/comments?post=541"}],"version-history":[{"count":1,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts\/541\/revisions"}],"predecessor-version":[{"id":544,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts\/541\/revisions\/544"}],"wp:attachment":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/media?parent=541"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/categories?post=541"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/tags?post=541"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}