Send WhatsApp messages from Node.js: a code example

Send WhatsApp messages from Node.js with built-in fetch — a first test with no template, then templates, safe retries, errors by type and a signed webhook that replies.

October 28, 2026 · 7 min read

To send a WhatsApp message from Node.js you need an API key and one fetch call. This guide starts with the quickest first test (message your own number, reply from code), then covers templates, retries that never double-send, errors by type, and a small webhook server that verifies the signature and answers.

SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. It runs on Meta's official WhatsApp Cloud API; you keep your own WhatsApp Business account.

What was tested: the code below was assembled from our REST API reference (read 28 Oct 2026) and run on Node 24 against a small local mock server: a template send, a free-form send, an error response, and the webhook (valid signature, repeated delivery, tampered signature, missing header, old timestamp). It was not run against a live SendFromChat key or real WhatsApp traffic. Use a test key first. Any current Node with built-in fetch (18 or newer) should work; we only ran Node 24.

The short version

  1. Create a test key on the dashboard's Developers page. It checks everything and sends nothing.
  2. POST /messages with an Idempotency-Key, so a retry never sends twice.
  3. Branch on the error type, not the message text.
  4. Verify SendFromChat-Signature on webhooks, over the raw body.

1. Set up

On the Developers page, create a test key (sfc_test_…). It runs every check a real send would (plan, opt-outs, template, 24-hour window), then stops: nothing is sent, saved or counted. Switch to a live key (sfc_live_…) when you go to production.

Keep the key on your server. Never put it in browser or mobile-app code.

export SENDFROMCHAT_KEY=sfc_test_...

No packages are needed. Save the code below as ES modules (.mjs files).

2. A small client

// send.mjs
const API = "https://sendfromchat.com/api/v1";

export class SendFromChatError extends Error {
  constructor(status, type, message) {
    super(`${type}: ${message}`);
    this.status = status;
    this.type = type;
  }
}

export async function sendMessage(payload, idempotencyKey) {
  const headers = {
    Authorization: `Bearer ${process.env.SENDFROMCHAT_KEY}`,
    "Content-Type": "application/json",
  };
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;

  const res = await fetch(`${API}/messages`, {
    method: "POST",
    headers,
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(10_000),
  });
  const body = await res.json();
  if (!res.ok) {
    const err = body.error ?? {};
    throw new SendFromChatError(res.status, err.type ?? "unknown", err.message ?? "");
  }
  return body; // { id: "wamid…", status: "accepted", … }
}

export const sendTemplate = (to, name, variables, idempotencyKey, language = "en_US") =>
  sendMessage({ to, template: { name, language, variables } }, idempotencyKey);

export const sendText = (to, text) => sendMessage({ to, text });

3. Your first test: text your own number

You do not need an approved template to see something work. Per our getting started guide, a reply to someone who messaged you is free-form text, no template, inside the 24-hour window. So:

  1. In the dashboard open Connect WhatsApp, click Continue with Facebook and pick your number in Meta's window. It must be a number that is not in use on the WhatsApp or WhatsApp Business app.
  2. From your own phone (a different number), send "hi" to the connected number. That opens the 24-hour window.
  3. Reply from code with a live key (a test key stops before sending):
import { sendText } from "./send.mjs";

console.log(await sendText("+491512345678", "Yes, it works."));

Replace the number with your own phone's. The response is 202 with "status": "accepted": the request was accepted, not yet delivered. Check GET /messages/{id} for sent, delivered or read.

Two honest limits. Templates (messages you start) need a card on your WhatsApp Business account at Meta before you can send them (details); the 30-day trial itself needs no card. And this flow is our reading of the docs, not something we ran live for this page.

4. Send a template

A message you start outside a 24-hour window must use a template Meta has approved (how the window works, template rules). Only message people who agreed to hear from you on WhatsApp (opt-in).

import { sendTemplate } from "./send.mjs";

await sendTemplate("+491512345678", "order_shipped", ["Ana", "#4821"], "order-4821-shipped");
  • to is international format with the country code. If your numbers come from forms or a spreadsheet, normalise them first (phone number format).
  • variables fill {{1}}, {{2}}… in order.
  • The idempotency key comes from the event, not the attempt. order-4821-shipped is the same on every retry, so a timeout or a re-run job gets the first response back instead of a second message. Reusing a key with a different body returns idempotency_mismatch.

With a test key the response is 200 with "status": "test". With a live key it is 202 accepted.

5. Handle errors by type

Errors share one shape, { "error": { "type": …, "message": … } }. Branch on type:

try {
  await sendTemplate("+491512345678", "order_shipped", ["Ana", "#4821"], "order-4821-shipped");
} catch (e) {
  if (["opted_out", "window_closed", "template_not_approved"].includes(e.type)) {
    // WhatsApp's rules, not a bug: log it and move on
  } else if (e.type === "warmup_limit") {
    // a new number hit today's cap: retry later
  } else if (e.status >= 500) {
    // safe to retry with the same Idempotency-Key
  } else {
    throw e;
  }
}

opted_out, window_closed, template_not_approved and whatsapp_rejected come back as 422. warmup_limit is a 429: brand-new numbers are capped at 50, 100, then 200 template sends a day for the first three days. The full table is in the REST API reference.

6. Receive replies: a webhook that answers

Add an endpoint on the Developers page and subscribe to message.received (and message.status for delivery updates). Each request carries SendFromChat-Signature: t=…,v1=…, where v1 is the hex HMAC-SHA256 of <t>.<raw request body> with your endpoint secret. Verify over the raw body, compare in constant time, and reject timestamps older than five minutes.

// webhook.mjs
import http from "node:http";
import crypto from "node:crypto";
import { sendText } from "./send.mjs";

export function verifySignature(rawBody, header, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

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

export function createServer(secret) {
  return http.createServer(async (req, res) => {
    if (req.method !== "POST" || req.url !== "/webhooks/sendfromchat") {
      res.writeHead(404).end();
      return;
    }
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const raw = Buffer.concat(chunks).toString("utf8");

    if (!verifySignature(raw, req.headers["sendfromchat-signature"], secret)) {
      res.writeHead(400).end();
      return;
    }
    res.writeHead(204).end(); // answer first, work after

    const event = JSON.parse(raw);
    if (seen.has(event.id)) return; // deliveries can repeat
    seen.add(event.id);

    if (event.type === "message.received") {
      console.log(`${event.data.from} wrote: ${event.data.text}`);
      await sendText(event.data.from, "Got it, thanks.").catch((e) => console.error(e.type ?? e));
    }
  });
}

// createServer(process.env.WEBHOOK_SECRET).listen(3000);

Why it is built this way:

  • Raw body. Re-serialised JSON no longer matches the signature, so the handler signs the exact bytes it received. (If you use Express, use express.raw({ type: "application/json" }) on this route; we did not run that.)
  • Answer first. Reply with a 2xx within 5 seconds; failures are retried with backoff for about a day. Do slow work after responding.
  • Skip repeats. Every body has an id like evt_…; deliveries can repeat.
  • The reply is free-form. It sits inside the 24-hour window the customer's message opened, so no template is needed. Replies, auto-replies and chatbot messages are unlimited on every plan; only messages you start count toward the monthly allowance (plans and limits). A test key stops before sending, so use a live key for the reply to go out.

The payloads for every event are in the REST API reference. For a Next.js version with STOP handling, see the webhooks guide. The Python equivalent is here.

Costs, in plain terms

Two separate things: SendFromChat's flat monthly plan (from $19), and Meta's per-message charge, which Meta bills to your own WhatsApp Business account at its published rates, with no markup from us. Rates depend on the message category and the recipient's country; see WhatsApp API pricing.

A note for EU and UK customers

SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. Read it before you decide; our GDPR guide covers consent, opt-out and records on your side.

Try it

Start the 30-day free trial (no card), connect a number, text it from your phone and run the reply snippet. If you build with Claude Code, you can ask it to "add WhatsApp order notifications to my app" and it writes the integration in your language (Claude Code guide). Weighing other routes? See SendFromChat vs Twilio and using Meta's Cloud API directly.

Do this from your AI assistant

SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.

Start free