WhatsApp Operators DailyThe Blueticks DispatchThursday, July 30, 2026
Productivity

How to Build a WhatsApp Bot in Node.js on Your Own Number (No Meta Verification, 2026)

Runnable Node code for a WhatsApp bot on the number your customers already have saved. Send, receive in Express, schedule follow-ups, and the production bits that stop double-sends.

DRBy Daniel Roth · July 30, 2026 · 14 min read
How to Build a WhatsApp Bot in Node.js on Your Own Number (No Meta Verification, 2026)

You want a bot that answers from the number your customers already have saved. Not a fresh number, not a Business verification queue, not a per-message bill. This is the whatsapp bot node js tutorial for that, and every snippet runs on a stock Node 22 install with no SDK.

What can a Node.js WhatsApp bot actually do on your own number, and what can't it?

A whatsapp bot nodejs on your own number can send text, media and polls, read your chat list, receive inbound messages over a webhook, and hold a scheduled send server-side. It cannot use Meta message templates, cannot safely reach strangers, and nobody can promise you an account will not be actioned.

What you get:

  • Send text, media and polls to contacts, groups and channels
  • Receive inbound messages and reactions as HTTPS webhook deliveries
  • Schedule a send up to 365 days out without running a scheduler
  • Read chats, messages and delivery acks, and quote-reply in-thread

What you do not get:

  • Meta message templates, or an Official Business Account badge
  • Any guarantee against a ban. WhatsApp's Terms of Service prohibit "bulk messaging, auto-messaging, auto-dialing, and the like," plus non-personal use it has not authorised.
  • Cold outreach. Consent is yours, and WhatsApp's advice is to only message people who contacted you first.

One framing note, because it decides everything downstream. This drives the number you already own, through WhatsApp Web or an always-on gateway linked as a device. It is not Meta's WhatsApp Business Platform, and that is not a marketing distinction: Meta's docs state that "numbers already in use with WhatsApp cannot be registered unless they are deleted first," and that a registered Cloud API number "cannot be used with WhatsApp Messenger" (business phone numbers). The official path costs you the number your customers know. The own-number REST API guide has the side-by-side.

Should you build on whatsapp-web.js or Baileys, or call a hosted API?

Build on the libraries when the session must live on your own infrastructure, or when nobody else may hold it. Call a hosted API when the bot is the product and reconnect loops, auth-state persistence and process supervision are not what you want to maintain. Both are honest answers.

This is a real fork, and for once it is in your own language. whatsapp-web.js and Baileys are Node libraries that predate every hosted own-number API, and thousands of bots run on them today. whatsapp-web.js "works by launching the WhatsApp Web browser application and managing it using Puppeteer," so you run a real Chromium per session; Baileys speaks the protocol over WebSockets with no browser, which is dramatically lighter.

What you own when you build:

You maintainwhatsapp-web.jsBaileys
Runtime costA Chromium process per numberA WebSocket, no browser
Auth stateLocalAuth needs a persistent filesystem, so it is "not compatible with hosts that provide ephemeral file systems"; RemoteAuth needs a session store: "either implement your own store or use already implemented ones" (wwebjs-mongo, wwebjs-aws-s3)You persist credentials yourself from the creds.update event
ReconnectYour own retry around client eventsYour own. After a QR scan "WhatsApp will forcibly disconnect you, forcing a reconnect," and you handle that restartRequired case yourself
Queue and supervisionYoursYours
Version driftBreaks when WhatsApp Web internals shiftBreaks when the protocol shifts

Two lines from the projects themselves, more useful than my opinion. whatsapp-web.js: "it is not guaranteed you will not be blocked by using this method. WhatsApp does not allow bots or unofficial clients on their platform, so this shouldn't be considered totally safe." And a code comment in Baileys' connection guide, on the helper most tutorials open with: "DONT EVER USE THE useMultiFileAuthState IN PROD."

Read those as maturity, not warning labels: they tell you where the maintenance sits, which is the axis to choose on. The Python counterpart walks the same fork.

Phone face-down beside a closed laptop, the linked-device pairing a bot session needs

What you need before the first line of Node: an API key, a paired number, and a chat ID

Three things, in this order: a bt_live_ API key, a WhatsApp number already paired to a live engine, and a recipient the API accepts. Pairing is the step people skip, and it is not optional. A send from an unpaired workspace has nothing to dispatch through, so it queues forever.

Pair by linking the account as a device, the way you link WhatsApp Web, or turn on the always-on gateway so it survives your laptop closing. WhatsApp allows up to four linked devices per primary phone, so a bot session does not cost you your desktop one. Verify from Node first:

const BASE = "https://api.blueticks.co";
const KEY = process.env.BLUETICKS_API_KEY;   // export BLUETICKS_API_KEY="bt_live_..."

async function api(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...init.headers },
  });
  const body = await res.json();
  if (!res.ok) {
    const e = body.error ?? {};
    throw new Error(`${res.status} ${e.code}: ${e.message} req=${res.headers.get("x-request-id")}`);
  }
  return body.data;
}

const engines = await api("/v1/engines");
if (engines.length === 0) throw new Error("No engine paired. Link a device first.");
console.log(await api("/v1/engines/me"));   // { phone, name, platform }

GET /v1/engines returns one entry per online engine with connected, state and hasSynced. An empty array is the failure you are checking for, not an error status. GET /v1/engines/me reports the number actually on the other end, the fastest way to catch "I paired the wrong account."

The recipient is an E.164 phone (+15551234567) or a WhatsApp JID (15551234567@c.us, 120363...@g.us for a group, ...@newsletter for a channel). To find one, GET /v1/chats returns chatId, name, chatType, unreadCount and lastMessageAt, paged with limit (1 to 200) and skip. Auth and the recipient-format rules live in the send endpoint reference.

How do you send your first WhatsApp message from Node.js in about 15 lines?

Post to /v1/scheduled-messages/{chatId} with a Bearer key and a flat JSON body. The recipient is the path segment, not a body field. Omit sendAt and it goes now; include it and the API holds the message. A 201 comes back wrapped as {"success": true, "data": {...}}.

// send.mjs  |  node 22, no dependencies
const BASE = "https://api.blueticks.co";
const KEY = process.env.BLUETICKS_API_KEY;

export async function send(chatId, text, { sendAt, idemKey } = {}) {
  const res = await fetch(`${BASE}/v1/scheduled-messages/${encodeURIComponent(chatId)}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      ...(idemKey ? { "Idempotency-Key": idemKey } : {}),
    },
    body: JSON.stringify({ type: "text", text, ...(sendAt ? { sendAt } : {}) }),
    signal: AbortSignal.timeout(30_000),
  });
  const body = await res.json();
  if (!res.ok) {
    const err = new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
    err.status = res.status;
    err.retryAfter = Number(res.headers.get("retry-after")) || null;   // seconds, on 429
    throw err;
  }
  return body.data;                    // { id, status, waMessageKey, sendAt, ... }
}

console.log(await send("+15551234567", "Bot is alive."));

That is the whole whatsapp api node js surface for sending: one endpoint, no SDK, no axios. Global fetch landed in Node v17.5.0 and stopped being experimental in v21.0.0, backed by undici (Node globals reference). Node 18 went end of life on 30 April 2025 and Node 20 on 30 April 2026, so target v22 or v24 (Node release schedule).

Three gotchas worth ten minutes now instead of an hour later:

  1. type is required. {text: "hi"} alone is a 400. For media it is {type: "media", mediaUrl: "https://..."}, https only.
  2. Do not post to the bare collection. POST /v1/scheduled-messages with the recipient in the body returns 410 Gone.
  3. text caps at 4096 characters. Mentions go inline as @[Display Name](<jid>), never as a structured field.

Every response carries an X-Request-Id header, echoed into error bodies as error.requestId. Log it.

How do you receive inbound messages in Express and route them to a stateful reply?

A node whatsapp webhook is one subscription plus one Express route that acknowledges immediately and only then decides on a reply. Create the subscription in the dashboard, not from code, because that is the path that issues a signing secret. Then spend your attention on routing and per-chat state that survives a restart.

That ordering is a real constraint, not a preference. A webhook created in the dashboard gets an HMAC signing secret. One created through POST /v1/webhooks does not: the create path stores only owner, url, events, description and enabled, so no secret exists, the response carries no signingSecret, and its deliveries go out unsigned. Worse, a handler that 401s them fails silently: a 401 is classified as a permanent failure, so the delivery is dropped after a single attempt with no retry, and it never counts toward the auto-disable threshold. Nothing tells you. You have to alert on failed deliveries yourself.

So register in the dashboard, then read the secret back over the API.

// api() returns body.data. A paged list flattens to
// { success, data: [...], limit, skip, total }, so this is already the array.
const webhooks = await api("/v1/webhooks");        // needs the webhooks:read scope
const hook = webhooks.find((h) => h.url.endsWith("/hooks/whatsapp"));
console.log(hook.signingSecret);   // whsec_... present for dashboard-created webhooks

The url must be https://, so for local work register a tunnel URL. A signed delivery arrives with Blueticks-Webhook-Signature (v1=<hex>, HMAC-SHA256 over {timestamp}.{rawBody}), Blueticks-Webhook-Timestamp in unix seconds, and X-Blueticks-Signature over the raw body alone. Their absence is exactly how you tell an unsigned webhook from a signed one. The verification handler (raw body, constant-time compare) is written out in full in the webhooks and auto-reply guide. Import it and move on.

Create it from code instead and you must protect the endpoint another way: an unguessable path, a shared token in the URL, or an IP allowlist. Treat the body as untrusted either way.

Two rules for what happens next. Acknowledge before you work: attempts time out after 10 seconds, and a timeout is treated as transient, so it retries up to 8 attempts over roughly 21 hours, and 8 consecutively exhausted deliveries do auto-disable the webhook. That contrast is the useful part. A slow handler fails loudly, in the end. A handler returning a permanent 4xx (anything but 408, 425 or 429) fails on the first attempt and stays quiet. Route on a table, not a chain of ifs, because state is per chat and must outlive the process.

import express from "express";
import { verify } from "./verify.js";          // from the webhooks guide
import { send } from "./send.mjs";

const app = express();
const seen = new Set();     // inbound message keys. Use Redis in production.
const step = new Map();     // chatId -> conversation step. Same.

const routes = [
  [/^(hi|hello|start)$/i, () => "Hi. Reply 1 for opening hours, 2 for a callback."],
  [/^1$/, () => "We are open 09:00 to 18:00, Monday to Friday."],
  [/^2$/, (chatId) => { step.set(chatId, "awaiting_time"); return "What time suits you?"; }],
];

app.post("/hooks/whatsapp", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req)) return res.sendStatus(401);
  res.sendStatus(200);                          // ACK FIRST. Then think.

  const event = JSON.parse(req.body.toString());
  const key = event.id?._serialized;            // WhatsApp's own message key
  if (!key || seen.has(key)) return;            // redelivery, already handled
  seen.add(key);
  if (event.id?.fromMe) return;                 // never reply to yourself

  const chatId = event.from?.id?._serialized ?? event.from;
  const text = (event.body ?? "").trim();

  let reply;
  if (step.get(chatId) === "awaiting_time") {
    step.delete(chatId);
    reply = `Booked for ${text}. We will confirm shortly.`;
  } else {
    reply = routes.find(([re]) => re.test(text))?.[1](chatId);
  }
  if (reply) await send(chatId, reply);
});

app.listen(3000);

express.raw() on that route only, so req.body stays the bytes the HMAC was computed over. And log one real delivery before you trust a field name: the payload is WhatsApp's message object flattened, so event.id._serialized, event.from and event.body are what you will see.

How do you schedule a follow-up from the bot without running your own cron?

Add sendAt to the same call. The API holds the message and dispatches it, so there is no worker to keep alive and no in-memory queue to lose on restart. sendAt must be RFC 3339 with an explicit offset, at least 10 seconds ahead, and no more than 365 days out.

const sendAt = new Date(Date.now() + 24 * 3600_000).toISOString();
await send(chatId, "Still want that callback? Reply YES and we will book it.", {
  sendAt,
  idemKey: `followup:${chatId}:${bookingId}`,
});

new Date().toISOString() produces 2026-07-31T09:00:00.000Z, which is accepted. What is not accepted is a hand-built local string with no offset, and what is not correct is "now plus 24 hours" when you meant "9am tomorrow in the recipient's timezone." Adding milliseconds moves an instant; a wall clock does not follow. Build the local time first, convert second. Those DST traps are worked through in the production scheduling guide, and they are language-independent.

Cancel with POST /v1/scheduled-messages/{id}/cancel. Expect one oddity: a cancelled message reports status: "failed" with failureReason: "cancelled by user", and a later GET on that id returns 404. You still want a nightly job for recurrence, since sendAt takes one instant, not a cron expression.

Ready to point your bot at a real number? Get a /v1 API key and have Node sending from your own WhatsApp number this afternoon. No Meta Business verification, no per-message template fees, no Chromium to babysit.

Network rack status LEDs at night, the always-on side of a production WhatsApp bot

How do you take the bot to production: idempotency, delivery acks, and safe pacing?

Three changes separate a demo from a whatsapp bot nodejs you can leave running: an idempotency key so a retry never double-sends, reading the delivery ack instead of trusting the 201, and bounded pacing with real 429 handling. Most first bots ship with none of them and duplicate on the first timeout.

1. Make retries and redeliveries idempotent

Send an Idempotency-Key header derived from the business object. An identical body under the same key replays the original response: same 201, same message id, no second message. A different body under it returns 409. Keys are workspace-scoped, capped at 64 characters. The secret body field is a correlation tag, not a dedupe key; sending it twice creates two messages.

The derivation rules are in the automation API guide. The Node-specific part is where the key sits: in an async worker pool it must be computed when the job is created, never when an attempt runs.

import pLimit from "p-limit";
const limit = pLimit(3);                            // bounded concurrency

const jobs = bookings.map((b) => ({
  chatId: b.phone,
  text: `Reminder: ${b.service} tomorrow at ${b.localTime}.`,
  idemKey: `booking:${b.id}:reminder`,              // per business object, ONCE
}));

await Promise.all(jobs.map((job) => limit(() => withRetry(job))));

Regenerate the key per attempt, or reach for crypto.randomUUID(), and every retry sends a fresh message. That is the most common duplicate-send bug in this space.

Inbound needs the mirror of it. Deliveries retry, so the same message can hit your handler twice. Dedupe on WhatsApp's own message key (event.id._serialized), not a timestamp and not the text, in Redis with a day-long TTL rather than a Set a restart wipes.

2. Read the ack, do not trust the 2xx

A 201 means the API accepted the message. It says nothing about delivery. The real ladder is five states plus a failure, and only four of them have a dedicated webhook event:

statusWhat it meansWhatsApp equivalentWebhook event
pendingAccepted, waiting to dispatchno tickmessage.queued
confirmedWhatsApp's server took it, waMessageKey now presentsingle tickmessage.delivered
receivedReached the recipient's devicedouble grey ticknone, read the ack
readOpeneddouble blue tickmessage.read
playedVoice note playedblue micnone, read the ack
failedDid not send, read failureReasonnonemessage.failed

Read that fourth column carefully, because one event name lies to you. message.delivered fires on confirmed, the server ack, not device delivery. For the double grey tick, subscribe to ack_changed_webhook instead: it carries the raw numeric ack (-1 error, 0 pending, 1 server, 2 device, 3 read, 4 played), and you branch on 2. There is no message.played event at all; played surfaces only as ack 4.

waMessageKey is the discriminator when something goes missing. It is an object of five fields (fromMe, remote, id, _serialized, plus participant on group messages) and only fromMe is guaranteed. Null values are stripped from every 2xx body, so an undispatched message has no waMessageKey property at all rather than a null one: test for absence, never for null. Absent means it never reached WhatsApp; present means the problem is downstream.

Subscribe rather than poll: message.queued, message.delivered, message.read and message.failed push transitions to you. Poll GET /v1/scheduled-messages/{id} only for a few high-stakes sends. On a free workspace polling dies fastest of all, because the 5-per-6-hour plan quota counts every read.

3. Pace it, and stop looping the send endpoint

The send bucket allows 60 requests per 60 seconds per API key. Separately, a free workspace gets 5 API requests per 6-hour UTC window across the whole /v1 surface. That second one is an evaluation quota: two sends and three status checks exhausts it. A subscription removes it.

A 429 tells you how long to wait rather than making you guess. Both limiters send Retry-After in seconds; the per-key bucket additionally sends X-RateLimit-Limit and X-RateLimit-Remaining. Honour the header, and fall back to backoff with jitter when it is absent:

const RETRYABLE = new Set([429, 500, 502, 503, 504]);

async function withRetry(job, attempts = 4) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await send(job.chatId, job.text, { idemKey: job.idemKey });
    } catch (err) {
      if (!RETRYABLE.has(err.status) || i === attempts - 1) throw err;
      // Server-stated wait wins; otherwise back off with jitter.
      const wait = err.retryAfter
        ? err.retryAfter * 1000
        : Math.min(30_000, 500 * 2 ** i) * (0.5 + Math.random());
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}

Never retry 400, 401, 403, 404, 409 or 410. None resolve on a second attempt, and a retried 409 compounds an idempotency conflict. I am not going to give you a safe messages-per-hour number, because there is not one: WhatsApp's enforcement is behavioural, not a published rate.

And looping the send endpoint is the wrong shape for one-to-many work anyway. There is no bulk send endpoint. Use POST /v1/audiences then POST /v1/campaigns, which handles pacing, per-contact substitution and progress counters for you.

A hand resting on a closed notebook and pencil beside a closed dark laptop, tea and reading glasses

How do you deploy the bot and keep it running?

Run the Express process anywhere with a stable HTTPS hostname, point the webhook at it, and health-check the WhatsApp session rather than the process. A whatsapp bot own number setup fails quietly: your Node app being up is not the same as your number being connected.

The deploy is unremarkable, which is the point: one process, one port, two secrets (BLUETICKS_API_KEY and the whsec_ signing secret), Redis for the dedupe set and per-chat state. No Chromium, no persistent volume, no session files.

Five readiness checks before you call it live:

  1. GET /v1/engines returns a non-empty array with connected: true.
  2. GET /v1/engines/me shows the number you expect.
  3. Send yourself a test and confirm status reaches received, not just confirmed.
  4. Message the bot from a second phone and confirm the reply lands.
  5. Replay one webhook body twice and confirm your dedupe drops the second.

What breaks, in the order it will bite you:

  • The session disconnects and your process stays green. Subscribe to session.disconnected and alert on it. A queued message with no engine dispatches nothing and reports no error.
  • The primary phone goes quiet. WhatsApp requires you to open the app on the primary phone at least once every 14 days to keep linked devices connected, and disconnects them after 30 days of inactivity (linked devices). A bot on a spare handset in a drawer dies on day 15.
  • A refactor breaks signature checks. Body-parsing middleware mounted above the webhook route breaks the HMAC, and every delivery starts 401ing. This is the outage that will not announce itself: a 401 is a permanent failure, so each delivery is dropped after one attempt, with no retry and no auto-disable to tip you off. Alert on failed deliveries, not just on your own error rate.

Then reconcile. Your database thinks it queued 400 reminders; GET /v1/scheduled-messages?status=pending is the only source of truth for what actually is. Diff nightly, because a message that never got queued produces no error. The own-number API reference covers the list path.

What a Node script can't give you: audiences, campaigns, and team visibility

A script sends. It does not give you a contact list a colleague can edit, a campaign with per-recipient personalisation and progress counters, or a shared view of what went out. Two of the three are API calls; the third is the dashboard the data already lands in.

POST /v1/audiences creates a named list, up to 1000 contacts per call, each carrying up to 32 custom string variables (keys start with a letter or underscore, values up to 1024 characters). POST /v1/campaigns targets it with single-brace tokens in the body ({firstName}, plus built-ins like {whatsappname} and {phone}), and reports live sentCount, deliveredCount, readCount and failedCount. onMissingVariable: "fail" is the default, and it rejects the campaign at create time rather than sending a message with {firstName} visible in it. Anything created through /v1 also appears in the dashboard, so a colleague can pause a campaign without you deploying. Triggered sequences build on this in the automation API guide.

FAQ

Can I build a WhatsApp bot in Node.js without the Meta API?

Yes. A whatsapp bot without meta api drives the number you already own through WhatsApp Web or a hosted gateway linked as a device, and you call a REST endpoint from Node. No templates, no Business verification, no per-message template fees. The trade: you stay subject to WhatsApp's Terms of Service and Messaging Guidelines, and carry the account risk yourself.

whatsapp-web.js, Baileys, or a hosted API?

Build on the libraries when the session has to stay on your own infrastructure. Baileys is a WebSocket client with no browser, far lighter than whatsapp-web.js, which runs a Chromium per session. Either way you own auth-state persistence, reconnection and supervision; a hosted API trades those for one HTTP call.

How do I stop my Node bot sending the same message twice?

Two guards. Outbound: an Idempotency-Key header derived from the business object, computed once when the job is created and reused on every retry. Inbound: dedupe on WhatsApp's message key (event.id._serialized) in Redis with a TTL, because deliveries retry up to 8 times.

How do I know a message was actually delivered?

Not from the 201. Read the ladder: pending, confirmed, received, read, played or failed. confirmed means WhatsApp's server took it and waMessageKey is now present; received is the double grey tick. Watch the event names, because message.delivered fires on confirmed (server ack), not on device delivery, and there is no message.played. For the double grey tick subscribe to ack_changed_webhook and branch on ack 2. An absent waMessageKey means it never reached WhatsApp.

What does the official Meta Cloud API give me that this doesn't?

Templates, an Official Business Account badge, and Meta's throughput tiers. It also costs you the number: Meta's docs say numbers already in use with WhatsApp must be deleted before registration, and a registered number cannot be used with WhatsApp Messenger. New business portfolios start at a messaging limit of 250, and since 1 July 2025 pricing is charged per delivered template message.

Ready to point Node at a real number? Get an API key and send from the number your customers already have saved, with no Meta verification and no per-message template fees.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.