
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
fetchbuilt 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.
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:
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:
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:
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:
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:
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" })
});
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:
{
"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.
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.
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:
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:
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.
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.
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:
sendMessagehas a duplicate filter that can reject a repeated, identical message. If your bot really needs to repeat a message, add"skip_filter": trueto 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:
- New to Maytapi? The getting started guide covers the account, QR pairing and webhook setup.
- Read every webhook payload, including acks, status and group events, in the webhook events guide.
- Build richer menus with lists and carousels from buttons, lists and carousels.
- Look up every endpoint and body in the API documentation.



