WhatsApp API & Developers

WhatsApp API with Node.js: Send, Receive and Reply

You want your Node app to send a WhatsApp message when something happens, and to react when a customer writes back. Most tutorials you find either pull in an abandoned SDK, skip the webhook signature check, or assume every inbound message carries a phone number, which stopped being safe in 2026.

This WhatsApp API Node.js guide uses nothing but the fetch built into Node 18 and later, plus Express for the webhook. You will send a text and a template, receive messages and statuses, verify that each webhook really came from Meta, and reply to the person who wrote. Every snippet runs as written.

At the end, the same flow on Tenashi's API, for teams that would rather send from the WhatsApp number they already use.

Key takeaways

  • Send with one fetch call to /PHONE_NUMBER_ID/messages. A 200 means Meta accepted the message, not that it was delivered.
  • Capture the raw request body in Express before parsing, or your X-Hub-Signature-256 check will fail on valid requests.
  • Answer the webhook with 200 first and do the work after, because Meta retries anything else.
  • Store the business-scoped user ID (BSUID) as well as the phone number. Some users now arrive without one.

What you need before writing code

You need four values from Meta and none of them is your phone number. If you have not created the app and test number yet, follow the WhatsApp Cloud API setup walkthrough first, then come back.

VariableWhere it comes from
WHATSAPP_TOKENA system user access token with WhatsApp permissions. The temporary token from the dashboard expires quickly.
PHONE_NUMBER_IDThe ID of the sending number in the app's WhatsApp API Setup panel. Not the WABA ID.
APP_SECRETApp settings, Basic. Used to check webhook signatures.
VERIFY_TOKENAny string you make up. Meta echoes it back once when you register the webhook.

The token is where most people lose an afternoon. Our guide to getting a permanent WhatsApp API access token covers the system user route step by step.

Set up the project as an ES module and install Express:

mkdir wa-node && cd wa-node
npm init -y
npm pkg set type=module
npm install express

# Node 20.6+ can load a .env file directly
node --env-file=.env server.js

On Node 18, export the variables in your shell or use the dotenv package instead of --env-file.

Send a text and a template with fetch

Sending is a single POST with a JSON body. Put it in a small module so the rest of your app never touches URLs or headers. The examples use Graph API v23.0; use the current version from Meta's changelog when you build.

// wa.js: a tiny Cloud API client using the fetch built into Node 18+
const GRAPH = "https://graph.facebook.com/v23.0";

async function post(payload) {
  const res = await fetch(`${GRAPH}/${process.env.PHONE_NUMBER_ID}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),
    signal: AbortSignal.timeout(10_000),
  });
  const data = await res.json();
  if (!res.ok) {
    const err = new Error(data.error?.message ?? `HTTP ${res.status}`);
    err.code = data.error?.code; // e.g. 190 = expired token, 100 = bad parameter
    throw err;
  }
  return data;
}

// who: { to: "+919811122233" } or, from July 2026, { recipient: "US.1349..." }
export async function sendText(who, body) {
  const data = await post({ recipient_type: "individual", ...who, type: "text", text: { body } });
  return data.messages[0].id; // the wamid: match it against status webhooks
}

export async function sendTemplate(who, name, language, params = []) {
  const data = await post({
    recipient_type: "individual",
    ...who,
    type: "template",
    template: {
      name,
      language: { code: language },
      components: [
        { type: "body", parameters: params.map((text) => ({ type: "text", text })) },
      ],
    },
  });
  return data.messages[0].id;
}

export async function markRead(wamid) {
  await post({ status: "read", message_id: wamid });
}

Two rules decide which function you call. Free-form text only goes through inside the 24-hour customer service window that opens each time the user messages you. Outside it, the message fails with error 131047, which Meta may report in the send response or later in a failed status webhook, and you must use an approved template instead. That is what sendTemplate is for. The 24-hour window explained has the full rules and the edge cases.

The who argument is there for 2026. Meta now lets you address a user by BSUID through a recipient field instead of to, which matters when the user has a username and you never saw their number. If you pass both, to wins.

Keep the returned wamid. The send response only confirms acceptance; whether the message was sent, delivered, read or failed arrives later as a status webhook carrying that same ID.

Receive webhooks in Express and check the signature

Meta talks to your server in two ways. A GET request verifies the callback URL once, when you save it in the App Dashboard. After that, every message and status arrives as a POST signed with your app secret.

// server.js
import crypto from "node:crypto";
import express from "express";
import { sendText, markRead } from "./wa.js";

const app = express();

// Keep the exact bytes Meta sent. The signature is over these, not over parsed JSON.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

// 1. Verification: Meta calls this once when you save the callback URL.
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 === process.env.VERIFY_TOKEN) {
    return res.status(200).type("text/plain").send(challenge);
  }
  res.sendStatus(403);
});

function signatureIsValid(req) {
  const received = Buffer.from(req.get("x-hub-signature-256") ?? "");
  const expected = Buffer.from(
    "sha256=" +
      crypto.createHmac("sha256", process.env.APP_SECRET).update(req.rawBody).digest("hex"),
  );
  // timingSafeEqual throws on different lengths, so check that first.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

// 2. Events: messages and statuses arrive here as POSTs.
app.post("/webhook", (req, res) => {
  if (!req.rawBody || !signatureIsValid(req)) return res.sendStatus(401);
  res.sendStatus(200); // acknowledge first, work after

  for (const entry of req.body.entry ?? []) {
    for (const change of entry.changes ?? []) {
      const value = change.value ?? {};
      for (const msg of value.messages ?? []) {
        handleMessage(msg).catch((err) => console.error("reply failed", err.code, err.message));
      }
      for (const status of value.statuses ?? []) {
        // sent, delivered, read or failed; failures carry errors[0].code (131047 etc.)
        console.log(status.id, status.status, status.errors?.[0]?.code ?? "");
      }
    }
  }
});

app.listen(process.env.PORT ?? 3000, () => console.log("listening"));

Three details in that file are where Node webhooks usually break.

The raw body. express.json() turns the body into an object. If you compute the HMAC over JSON.stringify(req.body), spacing and escaping can differ from what Meta signed and valid requests fail. The verify hook keeps the original buffer on req.rawBody. Our post on verifying the WhatsApp webhook signature goes deeper on this, including what to do behind proxies that rewrite bodies.

The comparison. crypto.timingSafeEqual throws a RangeError when the buffers have different lengths, which a garbage header will trigger. Check the length first, as above, or a bad request crashes the handler instead of getting a 401.

The order of operations. Meta retries deliveries that do not get a 200, with decreasing frequency for up to 7 days, and retries can produce duplicates. Replying to a WhatsApp user inside the request handler ties your response time to Meta's API, so acknowledge immediately and process afterwards.

To test locally, expose port 3000 through a tunnel such as ngrok or Cloudflare Tunnel. Meta needs a valid TLS certificate and does not accept self-signed ones. Then subscribe to the messages field on your app's WhatsApp configuration page. If verification succeeds but no messages ever arrive, the app is usually not subscribed to the WhatsApp Business Account itself; WhatsApp webhook not working walks through that check.

Reply to inbound messages, and plan for usernames

With the plumbing done, replying is a few lines. Add this to server.js:

const seen = new Set(); // use a database or Redis in production

async function handleMessage(msg) {
  if (seen.has(msg.id)) return; // Meta retries, so the same wamid can arrive twice
  seen.add(msg.id);

  // Users with a username may arrive without a phone number: fall back to the BSUID.
  const who = msg.from ? { to: msg.from } : { recipient: msg.from_user_id };

  await markRead(msg.id);
  if (msg.type === "text") {
    await sendText(who, `Thanks! We got "${msg.text.body.slice(0, 80)}" and will reply here shortly.`);
  }
}

The who line is the 2026 change most older Node examples miss. Webhooks now carry a business-scoped user ID in from_user_id and contacts[].user_id. When a user adopts a username, Meta may leave out from and wa_id entirely, so code that does msg.from.replace(...) throws. Store the BSUID next to the phone number from day one. The BSUID and usernames guide covers ID changes and migration.

The other change is cost. Since October 1, 2026, each business number gets 1,000 free service messages a month, then service replies are charged per message (as of October 2026, per Meta's pricing page). An auto-acknowledgement on every inbound message is no longer free at volume, so send it only where it helps.

The same flow on Tenashi's API

If the goal is to message customers from the WhatsApp number your team already uses, including inside groups, Tenashi's API follows the same shape with fewer moving parts: no templates, no window, a bearer key and one endpoint.

// tenashi.js
import crypto from "node:crypto";

const API = "https://usetenashi.com/api/v1";

export async function tenashiSend({ to, conversationId, text, idempotencyKey }) {
  const res = await fetch(`${API}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TENASHI_KEY}`,
      "Content-Type": "application/json",
      ...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
    },
    body: JSON.stringify(conversationId ? { conversationId, text } : { to, text }),
    signal: AbortSignal.timeout(10_000),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${data.error?.code}: ${data.error?.message}`);
  return data.message; // status "queued"; webhooks report sent, delivered, read
}

export function tenashiSignatureIsValid(req) {
  const timestamp = req.get("x-tenashi-timestamp") ?? "";
  const received = Buffer.from((req.get("x-tenashi-signature") ?? "").replace(/^v1=/, ""));
  const expected = Buffer.from(
    crypto
      .createHmac("sha256", process.env.TENASHI_WEBHOOK_SECRET)
      .update(`${timestamp}.`)
      .update(req.rawBody)
      .digest("hex"),
  );
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  return fresh && received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

And the receiving route, reusing the same req.rawBody capture:

import { tenashiSend, tenashiSignatureIsValid } from "./tenashi.js";

const seenEvents = new Set();

app.post("/tenashi", (req, res) => {
  if (!req.rawBody || !tenashiSignatureIsValid(req)) return res.sendStatus(401);
  res.sendStatus(200); // Tenashi wants a 2xx within 10 seconds

  const event = req.body;
  if (seenEvents.has(event.id)) return; // delivery is at least once
  seenEvents.add(event.id);

  if (event.type === "message.received" && event.data.message.chat.type === "dm") {
    const msg = event.data.message;
    tenashiSend({
      conversationId: msg.conversationId,
      text: `Hi ${msg.sender?.name ?? "there"}, thanks for your message. Someone from the team will reply here.`,
      idempotencyKey: `ack-${event.id}`,
    }).catch(console.error);
  }
});

The differences matter for how you write the caller. A send returns 202 with status queued, and the message leaves the number 4 to 12 seconds later at a human pace, so never await delivery inline. Replying by conversationId works for a group as well as a DM. The Idempotency-Key makes a retried request safe: a repeat within 24 hours returns the first response and sends nothing. Check chat.type before auto-replying so a bot never answers inside a busy group.

Where Tenashi fits

The code above is the right path when you need templates, OTPs or high-volume notifications on Meta's platform. Many teams reading a Node tutorial want something narrower: their app should post a booking confirmation or a lead alert from the number customers already know, and the sales or support team should see that message in the same thread when the customer replies.

Tenashi links your existing WhatsApp or WhatsApp Business number by QR scan, as a linked device. Messages your Node service sends through the API land in a shared team inbox with a CRM under every chat, and webhooks fire for DMs and groups alike. It is not Meta's API and has no templates, buttons or Flows, and sends are deliberately paced, so it is not built for thousands of messages a minute. WhatsApp can still ban numbers that spam; the pacing and new-chat limits reduce that risk but cannot remove it. Plans start from $18/mo.

Read the API docs · Get started

FAQ

Do I need an SDK to use the WhatsApp API from Node.js?

No. The Cloud API is plain HTTPS and JSON, so Node's built-in fetch covers sending, and any web framework covers webhooks. A 40-line module like the one above is easier to debug than a third-party SDK, keeps working when Meta changes versions, and has no dependency to audit. Express is the only package this tutorial installs.

Why does my webhook signature check fail when the secret is right?

Almost always because the HMAC is computed over re-serialised JSON instead of the raw bytes. Capture the body with the verify option of express.json() or use express.raw() on that route. Also confirm you are using the app secret, not the access token or verify token, and that a proxy is not modifying the body.

Why does my reply fail with error 131047?

More than 24 hours have passed since the user last messaged you, so the customer service window is closed. It often shows up as a failed status webhook rather than in the send response, so log errors on statuses. Send an approved template instead; when the user replies, free-form text works again for 24 hours.

Will this code work on Node 18?

Yes. fetch, AbortSignal.timeout and ES modules are all available in Node 18. The only Node 20 feature mentioned is the --env-file flag for loading a .env file; on 18, export the variables in your shell or load them with the dotenv package. Express 4 and 5 both support the verify hook used for the raw body.

Can I send a message to a WhatsApp group from Node.js?

On Meta's platform, only through the Groups API, which is limited to Official Business Accounts, groups the business creates by invite link, and 8 participants. It cannot reach groups your number is already in. With Tenashi's API you post into an existing group by passing its conversationId, which you get from the conversations endpoint or any webhook.


You now have a Node service that sends, receives, verifies and replies. For the bigger picture of templates, pricing and limits, keep the complete guide to the WhatsApp API open in a tab, and if your messages should come from your team's own number, read the Tenashi API docs.