Nou Safe Mode încălzește numerele noi pentru a reduce riscul de blocare pe WhatsApp. Află mai multe →

Maytapi/Blog/Tutorials

Build a WhatsApp Bot with Node.js: Send, Receive and Reply

Node.js tutorial: send a WhatsApp message with fetch, receive webhooks in Express and build a command bot that replies with text, media, location and buttons.

Tutorials Updated Oct 27, 2026·7 min read

Build a WhatsApp Bot with Node.js: Send, Receive and Reply

A WhatsApp bot is a small web server. WhatsApp messages come in as webhook calls, your code decides what to say, and the answer goes out through one HTTP request. This tutorial builds a working WhatsApp bot with Node.js and the Maytapi API. You'll send your first message with fetch, receive messages in Express, route commands, and reply with text, images, a location, reply buttons and a poll. At the end you'll find the checks that matter before you point real customers at it.

Maytapi is an unofficial WhatsApp API. You pair your own WhatsApp number as a linked device, the same way WhatsApp Web works, and drive it over REST. There are no message templates and no per-message fees. The trade-off is that the bot runs on a normal WhatsApp account, so WhatsApp's usual spam rules apply to it. We come back to that in the production section.

What you need before writing the bot

  • Node.js 18 or newer. It has fetch built in, so you don't need an HTTP client package.
  • A Maytapi account with a phone connected. The free trial runs for three days and doesn't ask for a card. Scan the QR code in the console with the WhatsApp number the bot should use.
  • Three values: your product ID, your API token and the phone ID.

The product ID and token are on the Product ID & Token page in the console (console.maytapi.com/settings/token). Every request sends the token in the x-maytapi-key header.

Maytapi console token page next to a first WhatsApp message sent with curl The product ID and token come from the console. One request is enough to send the first message.

The phone ID identifies the WhatsApp number inside your product. It's shown on the Phones page, and you can also get it from listPhones, which works at product level and has no phone ID in the path:

Terminal
curl "https://api.maytapi.com/api/$PRODUCT_ID/listPhones" \
  -H "x-maytapi-key: $TOKEN"

Each item in the response has an id (the phone ID), the number and a status. Your phone should show active before you continue.

Set up the project. Express is the only dependency:

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

Keep credentials out of the code and pass them as environment variables:

Terminal
export PRODUCT_ID="your-product-id"
export PHONE_ID="your-phone-id"
export TOKEN="your-token"

Send your first WhatsApp message from Node.js

Before you write any webhook code, check that sending works. Create send.js:

JavaScript
const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env;

const res = await fetch(
  `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/sendMessage`,
  {
    method: "POST",
    headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({
      to_number: "4072xxxxx67",
      type: "text",
      message: "Hello from my Node.js bot!"
    })
  }
);
console.log(await res.json());

Run node send.js with your own number in to_number: country code first, no +, no spaces. A successful call returns success: true with data.chatId and data.msgId. sendMessage adds the message to your phone's sending queue. If nothing arrives, check the phone's status and the message logs in the console.

For more languages and a few one-off examples, see the short reference Send a WhatsApp message with Node.js. The rest of this post goes past a single request.

Point Maytapi's webhook at your server

Incoming messages, delivery receipts (acks) and phone status changes are sent to one webhook URL. You set it at product level with setWebhook:

JavaScript
await fetch(`https://api.maytapi.com/api/${PRODUCT_ID}/setWebhook`, {
  method: "POST",
  headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" },
  body: JSON.stringify({ webhook: "https://example.com/maytapi/webhook" })
});
Workflow of the Node.js bot: a customer message is posted to the webhook, the handler returns 200 and skips fromMe, the router picks a reply and sends it.

You can also paste the URL on the console's webhook page. If one product runs several numbers and each should go to a different server, POST /config sets a webhook for a single phone. The webhook API page shows every payload type.

An incoming text message looks like this. The fields the bot uses are type, message, conversation and reply:

JSON
{
  "type": "message",
  "product_id": "your-product-id",
  "phone_id": "12345",
  "user": { "id": "4072xxxxx67@c.us", "name": "Customer", "phone": "4072xxxxx67" },
  "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "help", "fromMe": false },
  "conversation": "4072xxxxx67@c.us",
  "receiver": "4072xxxxx21",
  "timestamp": 1574081887,
  "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage"
}

conversation is the chat to answer in. reply is the sendMessage URL for the phone that received the message, so the handler doesn't have to build it.

Check the reply URL before you send your token. Anyone who learns your webhook address can post a fake event to it, so never send x-maytapi-key to a URL taken from the request without checking it. The send helper only accepts a reply that starts with https://api.maytapi.com/api/ followed by your own product ID. For extra protection, register the webhook with a hard-to-guess path or query parameter (for example /maytapi/webhook?key=…) and reject requests that don't carry it. If your firewall supports allowlists, the static IP option described in the webhook events guide lets you accept deliveries from one address only.

Build the Express webhook handler

Create bot.js. The handler returns 200 right away and does the work afterwards. It also skips your own outgoing messages (fromMe). Without that check the bot would answer itself forever.

JavaScript
import express from "express";

const { TOKEN, PRODUCT_ID } = process.env;
const app = express();
const REPLY_PREFIX = `https://api.maytapi.com/api/${PRODUCT_ID}/`;

async function send(replyUrl, body) {
  // webhooks are unauthenticated: only send the token to your own Maytapi product
  if (!replyUrl?.startsWith(REPLY_PREFIX)) return console.error("unexpected reply URL", replyUrl);
  const res = await fetch(replyUrl, {
    method: "POST",
    headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify(body)
  });
  const json = await res.json();
  if (!json.success) console.error("sendMessage failed", json);
}

app.post("/maytapi/webhook", express.json(), async (req, res) => {
  res.sendStatus(200); // acknowledge first

  const event = req.body;
  if (event.type !== "message" || event.message.fromMe) return;
  if (event.conversation.endsWith("@g.us")) return; // 1:1 chats only

  try {
    await handleMessage(event);
  } catch (err) {
    console.error(err);
  }
});

app.listen(3000, () => console.log("Bot listening on :3000"));

The group check is there on purpose. Group chat IDs end in @g.us, and a command bot that answers every message in a busy group gets annoying quickly. Remove that line once you've decided how the bot should behave in groups.

Route commands to text, media, location, buttons and polls

Commands live in a plain object, so adding one means adding one entry. Each entry returns the body for sendMessage without to_number, which the router fills in.

JavaScript
const commands = {
  help: () => ({
    type: "text",
    message: "Commands: *menu*, *photo*, *location*, *poll*"
  }),
  photo: () => ({
    type: "media",
    message: "https://example.com/images/store-front.jpg",
    text: "Our store this morning"
  }),
  location: () => ({
    type: "location",
    text: "Fatih Sultan Mehmet Bridge",
    latitude: "41.093292",
    longitude: "29.061737"
  }),
  menu: () => ({
    type: "buttons",
    message: "What would you like to do?",
    footer: "Acme Store",
    buttons: [
      { id: "order_status", text: "Order status" },
      { id: "talk_to_agent", text: "Talk to a person" }
    ]
  }),
  poll: () => ({
    type: "poll",
    message: "Which time suits you for delivery?",
    options: ["Morning", "Afternoon", "Evening"],
    only_one: true
  })
};

media takes a public URL or a base64 data URI in message, plus an optional caption in text. Images, videos, audio and documents all use this type, and you can add filename to control the name a document gets. location takes the coordinates as strings. A poll with only_one: true lets each person pick a single option. Votes come back to your webhook as pollVoteUpdate events, so you can count them as they happen.

The router reads the text, finds a command and sends the answer:

JavaScript
const answers = {
  order_status: "Send your order number and I'll look it up.",
  talk_to_agent: "Thanks, a team member will reply here shortly."
};

async function handleMessage(event) {
  const msg = event.message;
  if (msg.type !== "text") return;

  const key = (msg.payload || msg.text || "").trim().toLowerCase();
  const body = answers[key]
    ? { type: "text", message: answers[key] }
    : (commands[key] || commands.help)();

  await send(event.reply, { to_number: event.conversation, ...body });
}

A choice from a list message reaches the webhook as a text message with the row title in text and its rowId in payload, and reply-button taps usually arrive the same way with the button id in payload. The router reads payload first and falls back to text, so taps and typed commands go through the same code. Button replies are not delivered identically on every connection, so test them on your own number, and if a flow must know the exact choice, send a list instead of buttons. For lists, carousels and link, call or copy-code buttons, see the interactive messages API.

Test the bot locally with a tunnel

Maytapi has to reach your webhook over the public internet, and localhost:3000 isn't reachable from outside. While you develop, run a tunnel in a second terminal. Use it as a command-line tool, not as a library inside the bot:

Terminal
node bot.js
# in another terminal, either one:
cloudflared tunnel --url http://localhost:3000
ngrok http 3000

Copy the HTTPS URL the tunnel prints, add /maytapi/webhook, and register it with setWebhook (or paste it in the console). Then send help, menu, photo or poll to the bot's number from a different phone. Messages you type on the bot's own phone arrive with fromMe: true, and the handler ignores them.

Free tunnel URLs usually change on every restart. When the bot stops replying, check the webhook URL first. When you deploy, register the server's stable HTTPS URL and stop using the tunnel.

Production tips for a Node.js WhatsApp bot

Respond 200 fast. The handler above sends the status before it calls any API or database. Anything slow, like an AI model or an order lookup, should run after the response or on a job queue, so webhook requests never pile up.

Production checklist for a Node.js WhatsApp bot: fast 200 responses, idempotency, ack and status events, pacing, secrets and the duplicate filter.

Make handling idempotent. Use message.id as the key and skip IDs you've already processed. Then a redeploy or a duplicate delivery never sends a customer the same reply twice. An in-memory Set is fine for a demo. With more than one process, use Redis or a unique database column.

JavaScript
const seen = new Set();

function firstTime(id) {
  if (seen.has(id)) return false;
  seen.add(id);
  return true;
}
// in the handler: if (!firstTime(event.message.id)) return;

Handle the other event types. ack events tell you whether a message was delivered, reached the phone or was seen. status events tell you when the phone disconnects or falls back to the QR screen, which is the moment to alert someone. Ack events are off by default; turn them on with setAckPreference (ack_delivery: true) when you need receipts.

Pace your messages. The number is a real WhatsApp account, and WhatsApp can restrict accounts that look like spam. A bot that only answers people who write first carries little risk. Messaging many new contacts in a burst carries much more. New numbers should warm up slowly. Read how Safe Mode prevents WhatsApp bans before you send anything proactive.

Note: sendMessage has a duplicate filter that can reject a repeated, identical message. If your bot really needs to repeat a message, add "skip_filter": true to that request rather than turning it on everywhere.

Keep secrets in the environment. Load the token from environment variables or a secret manager, never from source control.

Next steps

You now have a Node.js bot that receives messages, routes commands and replies with five message types. From here you can:

Published Sep 4, 2024 · Updated Oct 27, 2026 ← All posts
Începe să folosești API-ul WhatsApp

Obține cheia și trimite primul mesaj în zece minute

Fără aprobare, fără șabloane, fără card. Toate endpointurile sunt deschise timp de trei zile.