Build a WhatsApp Business Bot
Connect a Node.js webhook server to the WhatsApp Cloud API for auto-replies.
Introduction
30 Second Summary
Businesses send messages on WhatsApp every day, but many get restricted or banned because they use unofficial tools or break messaging rules they didn't know existed.
In this project, you will build a WhatsApp bot using the official WhatsApp Cloud API that automatically replies to incoming messages and sends template messages without any risk of getting restricted.
What You'll Build
A Node.js Express server connected to the WhatsApp Cloud API that receives incoming messages via webhooks and auto-replies within the free 24-hour customer service window.
By the end of this project, you'll have:
- A Node.js Express webhook server that verifies and receives WhatsApp messages from Meta's infrastructure.
- Auto-reply logic that responds to incoming messages with keyword-based replies within the free service window.
- A /send-template endpoint for sending pre-approved message templates to initiate conversations compliantly.
- Secret Mission: Add message logging with opt-in tracking to monitor your compliance health.
Are there any prerequisites?
You should have basic familiarity with Node.js (running scripts and installing packages).
You'll also need a Facebook account for Meta Developer registration and a WhatsApp-enabled phone for testing.
Before We Start
Before you can build a WhatsApp bot, you need a local development environment that can talk to Meta's servers. This means a Node.js project with the right packages, a tunnel tool that exposes your local machine to the internet, and a basic server to confirm everything works.
In this step, you'll set up your project folder, install your dependencies, and get a simple Express server running on port 3000. You'll also install Cloudflare Tunnel, a free tool that creates a public URL pointing to your local server.
In this step, get ready to:
- Initialize a Node.js project and install Express, axios, and dotenv.
- Install cloudflared for tunneling your local server to the internet.
- Create a basic Express server and verify it starts on port 3000.
Initialize the Node.js project and install dependencies
Every Node.js project starts with a package.json file that tracks your project's name, version, and dependencies. You'll create one now and install the three packages your bot needs.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, top left of your keyboard).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir whatsapp-bot
cd whatsapp-bot
- Open this folder in VS Code by running:
code .
What does code . do?
The code . command opens VS Code with the current folder as your workspace. The dot (.) means "this directory."
- If VS Code opens a new window, open a fresh terminal in it by pressing Ctrl+`.
- Initialize your Node.js project by running:
npm init -y
What does npm init -y do?
This creates a package.json file with default values. The -y flag skips all the prompts and accepts the defaults so you don't have to answer questions.
- Install the three packages your bot needs by running:
npm install express@5.2.1 axios@1.18.1 dotenv@17.4.2
What are these packages?
- express is a web framework that creates your server and handles incoming requests (like webhook messages from Meta).
- axios is an HTTP client that sends outgoing requests (like replying to WhatsApp messages via the Graph API).
- dotenv loads environment variables from a .env file so you can keep secrets out of your code.
You should see a success message in your terminal listing the installed packages.
- Create the .env file by running:
touch .env
- Open .env in the VS Code Explorer sidebar (left panel) and paste this content:
PORT=3000
VERIFY_TOKEN=your-verify-token-here
ACCESS_TOKEN=your-access-token-here
PHONE_NUMBER_ID=your-phone-number-id-here
What are these values?
These are placeholder credentials you'll replace with real values once you set up your Meta Developer App in the next step. VERIFY_TOKEN is a secret passphrase you choose. ACCESS_TOKEN and PHONE_NUMBER_ID come from Meta's dashboard.
- Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing permission errors during install?
Make sure you're running the terminal inside the whatsapp-bot folder. Run pwd to check your current directory. It should end with /whatsapp-bot.
Help me fix my npm install errors.
Install cloudflared for tunneling
Meta needs a public URL to send webhook events to your server. Since your server runs on localhost, it's invisible to the internet by default. Cloudflare Tunnel solves this by creating a free public URL that routes traffic to your local machine.
- Check if cloudflared is already installed by running:
cloudflared --version
✔️ I see a version number
You're all set. Move on to the next substep.
⨯ Command not found
You need to install cloudflared. Follow the instructions for your operating system below.
macOS
- Install cloudflared using Homebrew by running:
brew install cloudflared
- Verify the installation by running:
cloudflared --version
You should see a version number printed in the terminal.
Windows
- Open a PowerShell window and install cloudflared by running:
winget install --id=Cloudflare.cloudflared -e
- Close and reopen your terminal, then verify the installation by running:
cloudflared --version
You should see a version number printed in the terminal.
Why Cloudflare Tunnel instead of ngrok?
Both tools create public URLs for local development. Cloudflare Tunnel is free with no session time limits, handles HTTPS automatically, and works the same way in both development and production.
Still seeing command not found after installing?
On Windows, you may need to close and reopen your terminal (or VS Code entirely) for the PATH to update. On macOS, make sure Homebrew is installed first. Run brew --version to check.
Help me get cloudflared working on my machine.
Create the project file structure and verify it works
Now you'll create index.js, your main server file. This basic server confirms your environment is working before you add webhook logic later.
- Create index.js inside your whatsapp-bot folder by running:
touch index.js
- Open index.js in the VS Code Explorer sidebar and paste this code:
require('dotenv').config();
const express = require('express');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
// Health check
app.get('/', (req, res) => {
res.send('WhatsApp Bot is running.');
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- require('dotenv').config() loads the variables from your .env file into process.env.
- express() creates your web server. express.json() tells it to parse incoming JSON request bodies (needed for webhooks).
- The / route is a health check. If you visit your server in a browser, it confirms the bot is alive.
- app.listen starts the server on port 3000.
- Save index.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Start your server by running:
node index.js
You should see Server running on port 3000 printed in the terminal.
- Open your browser and go to http://localhost:3000.
You should see the text "WhatsApp Bot is running." displayed in the browser.
- Go back to your terminal and press Ctrl+C to stop the server.
Seeing Error: Cannot find module 'dotenv'?
This means the packages didn't install correctly. Make sure you're in the whatsapp-bot folder (check with pwd) and run npm install again. You should see a node_modules folder appear in your project.
Help me fix my server startup error.
- Confirm all your files exist by running:
ls
You should see .env, index.js, node_modules, package-lock.json, and package.json in the output.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
⨯ I'd like to double check the full code
Here's what each file should look like at this point.
index.js:
require('dotenv').config();
const express = require('express');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('WhatsApp Bot is running.');
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
.env:
PORT=3000
VERIFY_TOKEN=your-verify-token-here
ACCESS_TOKEN=your-access-token-here
PHONE_NUMBER_ID=your-phone-number-id-here
package.json:
{
"name": "whatsapp-bot",
"version": "1.0.0",
"description": "WhatsApp Cloud API Bot with auto-reply",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "5.2.1",
"axios": "1.18.1",
"dotenv": "17.4.2"
}
}
Your development environment is ready. Next up, you'll create your Meta Developer App and get access to the WhatsApp Cloud API.
Create Your Meta Developer App
Your Express server is running locally, but it has no connection to WhatsApp yet. To send and receive real messages, you need to register your bot with Meta's WhatsApp Cloud API infrastructure.
In this step, you'll create a Meta Developer App, connect it to a WhatsApp Business Account, and generate a permanent access token. You'll also send your first template message to confirm everything works.
In this step, get ready to:
- Create a Meta Developer App with the WhatsApp use case.
- Configure API access and send a test template message.
- Generate a permanent access token via a System User.
Create a Meta app with the WhatsApp use case
The Meta App Dashboard is where you register applications that use Meta's APIs. You'll create a new app here specifically for WhatsApp messaging.
- Open your browser and go to developers.facebook.com.
- Sign in with your Facebook account.
- Click Create App on the App Dashboard page.
- Enter a name for your app (e.g. WhatsApp Bot) and your contact email address.
- Select the Connect with customers through WhatsApp use case.
- Click Next.
- Select an existing business portfolio or create a new one.
- Review your details and click Create app.
After creation, you'll be redirected to the Quickstart page for your new app.
What is a business portfolio?
A business portfolio is Meta's way of grouping your business assets (apps, ad accounts, WhatsApp accounts) under one umbrella. If you don't have one yet, Meta will prompt you to create one during app setup.
Not seeing the WhatsApp use case?
Make sure you're signed in with a personal Facebook account (not a managed business account). The WhatsApp use case only appears for registered developers.
If you haven't registered as a Meta developer yet, visit the developer registration page at developers.facebook.com and follow the prompts before creating your app.
help me register as a Meta developer so I can see the WhatsApp use case
Configure API access and send a test message
Now that your app exists, you'll connect it to a WhatsApp Business Account and send your first message to confirm everything is wired up correctly.
- On the Quickstart page, click Start using the API.
- Connect your app to a WhatsApp Business Account. Select an existing account from the dropdown, or create a new one if prompted.
- Click Generate access token to create a temporary token for testing.
- Note your Phone Number ID and WhatsApp Business Account ID displayed on the API Setup page. You'll need both shortly.
- Add your personal phone number as a To recipient in the test message section.
- Click Send message to send the hello_world template to yourself.
- Check your phone. You should receive a WhatsApp message from your test business number.
What is the hello_world template?
Every new WhatsApp Business Account comes with a pre-approved hello_world template. Template messages are the only way to initiate a conversation with someone outside the 24-hour customer service window.
You'll learn more about why templates matter for compliance in a later step.
Now update your local credentials so your server can authenticate with Meta's API.
- Switch back to VS Code and open your .env file.
- Find the line ACCESS_TOKEN=your-access-token-here and replace your-access-token-here with the temporary token you just generated.
- Find the line PHONE_NUMBER_ID=your-phone-number-id-here and replace your-phone-number-id-here with your Phone Number ID from the API Setup page.
- Save .env.
Keep this token temporary
The token you just pasted expires quickly. You'll replace it with a permanent one in the next substep. This temporary token just confirms your setup works.
Didn't receive the test message?
Make sure you entered your phone number in full international format (e.g. country code followed by the number, no dashes or spaces).
Also confirm your phone number is registered on WhatsApp. The test message is sent via WhatsApp, not SMS.
help me troubleshoot why I'm not receiving the hello_world test message from Meta
Generate a permanent access token via System User
The temporary token expires quickly and is not suitable for ongoing development. A System User token is permanent and tied to your business assets rather than your personal account.
- Open Meta Business Settings by navigating to business.facebook.com/settings in your browser.
- Click System users in the left sidebar.
- Click the Add button in the upper-right corner.
- Enter a name for your system user (e.g. whatsapp-bot) and set the role to Admin.
- Click Create system user.
Next, assign the correct assets to your system user so it can access your app and WhatsApp account.
- Select the system user you just created.
- Click Assign Assets.
- Select your app and toggle Manage app under Full control.
- Select your WhatsApp account and toggle Manage WhatsApp Business accounts under Full control.
- Click Assign assets.
Now generate the permanent token with the correct permissions.
- Click Generate token.
- Select your app from the dropdown.
- Add the following permissions: whatsapp_business_messaging and whatsapp_business_management.
- Click Generate token.
- Copy the generated token and store it somewhere safe. This token will not be shown again.
Why these two permissions?
The whatsapp_business_messaging permission allows your app to send and receive messages. The whatsapp_business_management permission allows it to manage phone numbers and webhook subscriptions.
Finally, replace the temporary token in your project with the permanent one.
- Switch back to VS Code and open your .env file.
- Replace the value next to ACCESS_TOKEN= with the permanent system user token you just copied.
- Save .env.
Can't find the System users option?
System Users lives under Meta Business Settings, not the App Dashboard. Make sure you're at business.facebook.com/settings and not developers.facebook.com.
If you don't see the option, make sure your account has admin access to the business portfolio you selected during app creation.
help me find the System Users section in Meta Business Settings
Your Meta app is fully configured with a permanent access token. Next up, you'll build the webhook endpoint that receives incoming messages and expose it to the internet using Cloudflare Tunnel.
Build the Webhook Server
Your Meta app is configured and your credentials are ready. Now you need a way for Meta to actually send incoming WhatsApp messages to your server.
Meta delivers messages through a webhook. When someone messages your business number, Meta sends that data to a URL you control. In this step, you will build the webhook endpoint, expose your local server to the internet using Cloudflare Tunnel, and verify the connection end-to-end.
In this step, get ready to:
- Add a webhook verification route to your Express server.
- Expose your local server via Cloudflare Tunnel and register the webhook URL with Meta.
- Send a test message and confirm Meta delivers it to your server.
Add webhook verification to your Express server
Before Meta will send any messages to your webhook URL, it first verifies that the URL actually belongs to you. Meta sends a GET request with a token you defined, and your server must respond correctly to prove ownership.
- In VS Code, open index.js.
- Replace everything above the health check route (app.get('/', ...)) with this updated configuration:
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
const VERIFY_TOKEN = process.env.VERIFY_TOKEN;
const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
const PHONE_NUMBER_ID = process.env.PHONE_NUMBER_ID;
What does this code do?
This loads your .env credentials into named constants so the rest of your code can reference them easily.
- axios is an HTTP client you will use later to send replies back through the WhatsApp Cloud API.
- VERIFY_TOKEN is the secret string you defined in .env. Meta will send this back to you during verification to prove the request came from them.
- ACCESS_TOKEN and PHONE_NUMBER_ID authenticate your outgoing API calls.
- Now add the webhook verification route. Find the health check route (app.get('/', ...)) and paste this code directly below it:
// Webhook verification (Meta sends GET to verify your endpoint)
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 === VERIFY_TOKEN) {
console.log('Webhook verified successfully.');
res.status(200).send(challenge);
} else {
console.log('Webhook verification failed.');
res.sendStatus(403);
}
});
What does this code do?
This is Meta's verification handshake. When you register your webhook URL, Meta sends a GET request with three query parameters:
- hub.mode will be subscribe when Meta is trying to verify your endpoint.
- hub.verify_token is the secret you chose. If it matches your VERIFY_TOKEN, you know the request is legitimate.
- hub.challenge is a random string Meta expects back. Responding with it proves your server is alive and authorized.
- Save index.js.
Seeing a syntax error?
Make sure the app.get('/webhook', ...) route sits between the health check route and the app.listen(...) call at the bottom. Routes defined after app.listen will not work.
Also check that every opening brace { and parenthesis ( has a matching closing one.
help me debug a syntax error in my Express webhook route
✔️ Awesome, I've got everything!
Great. Make sure you have saved index.js before moving on.
ⓧ I'd like to double check the full code
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
const VERIFY_TOKEN = process.env.VERIFY_TOKEN;
const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
const PHONE_NUMBER_ID = process.env.PHONE_NUMBER_ID;
// Health check
app.get('/', (req, res) => {
res.send('WhatsApp Bot is running.');
});
// Webhook verification (Meta sends GET to verify your endpoint)
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 === VERIFY_TOKEN) {
console.log('Webhook verified successfully.');
res.status(200).send(challenge);
} else {
console.log('Webhook verification failed.');
res.sendStatus(403);
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Start Cloudflare Tunnel and register the webhook with Meta
Your server can handle verification requests, but it is only reachable on your machine at localhost:3000. Meta needs a public HTTPS URL to reach it. Cloudflare Tunnel creates a free public URL that forwards traffic to your local server.
- Start your Express server by running this command in the VS Code terminal:
node index.js
- You should see Server running on port 3000 in the terminal output.
- Open a second terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- Start Cloudflare Tunnel by running this command in the new terminal:
cloudflared tunnel --url http://localhost:3000
What does this command do?
This creates a temporary public URL (something like https://random-words.trycloudflare.com) that tunnels all incoming traffic to your local server on port 3000. The URL changes every time you restart the tunnel.
- Look for a line in the terminal output that contains a .trycloudflare.com URL. Copy this full URL.
Now you will register this URL as your webhook in the Meta App Dashboard.
- Switch back to the Meta App Dashboard in your browser.
- In the left sidebar under your WhatsApp use case, click Configuration.
- In the Webhook section, click Edit.
- In the Callback URL field, paste your trycloudflare.com URL and append /webhook to the end. For example: https://random-words.trycloudflare.com/webhook.
- In the Verify token field, enter the value you set for VERIFY_TOKEN in your .env file (your-verify-token-here unless you changed it).
- Click Verify and Save.
Meta sends the verification GET request to your tunnel URL. If everything is set up correctly, you will see Webhook verified successfully. logged in your first terminal.
- After verification succeeds, find the messages field in the Webhook fields list and click Subscribe next to it.
Why subscribe to messages?
Subscribing to the messages field tells Meta which events to forward. Without this subscription, Meta would verify your URL but never actually send incoming message data to it.
Verification failed?
Check that your Node.js server is still running in the first terminal (you should see Server running on port 3000).
Confirm that the VERIFY_TOKEN value in your .env file matches exactly what you typed into the Meta dashboard. Extra spaces or different casing will cause a mismatch.
Also make sure you included /webhook at the end of your Callback URL. Without it, Meta hits your health check route instead of the verification handler.
help me troubleshoot my Meta webhook verification failure
Verify the connection works
The webhook is registered. Time to confirm that Meta actually delivers incoming messages to your server.
- On your phone, open WhatsApp and send any text message to the test business number shown in your Meta App Dashboard.
- Switch back to the first terminal in VS Code (the one running node index.js).
You should see a JSON payload logged in the terminal. This is Meta forwarding the incoming message data to your webhook endpoint. The payload contains nested objects under body.entry[].changes[].value.messages[].
What should I see?
The logged payload is a JSON object from Meta containing your message details. Look for "messages" in the output. You will see the sender's phone number in the from field and your message text in text.body.
If you see this, it means Meta is successfully routing WhatsApp messages through your tunnel to your local server.
Not seeing any output?
Make sure you are messaging the test business number (not your own number). The test number is displayed in the Meta App Dashboard under API Setup.
Check that the cloudflared process is still running in your second terminal. If it disconnected, restart it and update the Callback URL in Meta's dashboard with the new .trycloudflare.com URL.
help me figure out why my WhatsApp webhook is not receiving messages
Your webhook server is live and receiving messages. Next up, you will parse those incoming payloads and build auto-reply logic that responds within the free 24-hour customer service window.
Handle Incoming Messages and Auto-Reply
Your webhook is verified and Meta is delivering messages to your server. Right now, those messages arrive and disappear into the void.
In this step, you'll add the logic to parse incoming WhatsApp Cloud API payloads and automatically reply to messages. These replies happen within the free 24-hour customer service window, meaning you pay nothing for them.
In this step, get ready to:
- Add a POST /webhook route that receives incoming message payloads from Meta.
- Build keyword-based auto-reply logic that responds to incoming messages.
- Test the auto-reply flow end-to-end from your phone.
Add the POST /webhook handler to receive messages
When someone sends a WhatsApp message to your test business number, Meta packages it as a JSON payload and POSTs it to your /webhook endpoint. Your server needs to parse that payload and immediately return HTTP 200 so Meta knows you received it.
Why return 200 immediately?
Meta retries webhook deliveries with decreasing frequency for up to 7 days if your endpoint doesn't respond with a 200 status. Returning 200 right away prevents duplicate message processing while you handle the reply asynchronously.
- In index.js, find the app.listen block at the bottom of your file. Add the following POST route above it:
// Receive incoming messages (Meta sends POST with message data)
app.post('/webhook', (req, res) => {
const body = req.body;
if (body.object === 'whatsapp_business_account') {
body.entry.forEach((entry) => {
entry.changes.forEach((change) => {
if (change.field === 'messages') {
const messages = change.value.messages;
if (messages) {
messages.forEach((message) => {
handleIncomingMessage(message);
});
}
}
});
});
res.sendStatus(200);
} else {
res.sendStatus(404);
}
});
What does this code do?
Meta wraps incoming messages in a deeply nested structure: body.entry[].changes[].value.messages[]. This route drills into that structure to find actual messages.
The if (body.object === 'whatsapp_business_account') check ensures you only process WhatsApp-specific events. The route always returns 200 before doing any reply work, so Meta never retries.
- Save index.js.
You'll see a reference error if you run the server now because handleIncomingMessage doesn't exist yet. You'll add it in the next chunk.
Build the auto-reply function
Your POST route calls handleIncomingMessage for each message, but that function doesn't exist yet. You need two functions: one that decides what to reply based on keywords, and one that actually sends the reply through the Graph API.
- In index.js, add the following function below your POST /webhook route (and above app.listen):
// Handle incoming message and auto-reply
async function handleIncomingMessage(message) {
const from = message.from;
const type = message.type;
console.log(`Received ${type} message from ${from}`);
if (type === 'text') {
const incomingText = message.text.body.toLowerCase();
let replyText = '';
if (incomingText.includes('hello') || incomingText.includes('hi')) {
replyText = 'Hello! Welcome to our business. How can we help you today?';
} else if (incomingText.includes('hours') || incomingText.includes('open')) {
replyText = 'We are open Monday to Friday, 9 AM to 6 PM.';
} else if (incomingText.includes('help')) {
replyText = 'Sure! Please describe your question and our team will assist you shortly.';
} else {
replyText = 'Thank you for your message! Our team will get back to you shortly.';
}
await sendTextMessage(from, replyText);
}
}
What does this code do?
This function extracts the sender's phone number and message type from the webhook payload. It only processes text messages (images, stickers, and voice notes are ignored for now).
The keyword matching uses .toLowerCase() so it catches "Hello", "HELLO", and "hello" equally. Each keyword triggers a different reply, with a default catch-all for anything else.
- Now add the sendTextMessage function directly below handleIncomingMessage:
// Send a text message via the Cloud API
async function sendTextMessage(to, text) {
try {
const response = await axios.post(
`https://graph.facebook.com/v25.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: to,
type: 'text',
text: { body: text }
},
{
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Message sent to ${to}: ${response.data.messages[0].id}`);
} catch (error) {
console.error('Error sending message:', error.response?.data || error.message);
}
}
What does this code do?
This function POSTs to the WhatsApp Cloud API messages endpoint at https://graph.facebook.com/v25.0/{PHONE_NUMBER_ID}/messages. The payload includes messaging_product, recipient_type, the recipient's phone number, the message type, and the text body.
Your permanent access token is sent in the Authorization header. The try/catch block logs the message ID on success and the error details on failure, so you can debug delivery issues in your terminal.
What is the 24-hour customer service window?
When a customer messages you first, a 24-hour timer starts. Within that window, you can send unlimited free replies. The timer resets every time the customer sends a new message.
Outside that window, you can only reach people using approved template messages (covered in the next step). This is how Meta prevents spam while keeping service replies fast and free.
- Save index.js.
- Switch to the terminal where your server is running. Press Ctrl+C to stop it.
- Restart the server by running:
node index.js
You should see Server running on port 3000 in your terminal. Your bot is now ready to receive and reply to messages.
Seeing a syntax error on startup?
- Check that both functions are placed between the POST /webhook route and the app.listen call. Placing them inside another route causes nesting errors.
- Make sure every opening brace { has a matching closing brace }. The POST route, handleIncomingMessage, and sendTextMessage each have their own set of braces.
- Confirm you have a comma after each property in the axios payload object.
Help me debug a syntax error in my index.js webhook handler
Test the auto-reply flow end-to-end
Your bot is running and your Cloudflare Tunnel is still forwarding traffic from Meta. Time to see the auto-reply in action.
- Open WhatsApp on your phone.
- Find the conversation with the test business number (the one that sent you the hello_world template earlier).
- Send the message hello to the test business number.
Within a few seconds, your bot should reply with: "Hello! Welcome to our business. How can we help you today?"
- Check your server's terminal output. You should see a log line like Received text message from <your-number> followed by Message sent to <your-number>: <message-id>.
- Send a few more test messages to confirm different keywords trigger different replies:
- Send What are your hours? to get business hours.
- Send I need help to get the support reply.
- Send anything else (like pricing) to get the default reply.
Not getting a reply on WhatsApp?
- Check that your cloudflared tunnel is still running in the second terminal. If you closed it, restart it with cloudflared tunnel --url http://localhost:3000 and update the webhook URL in Meta's dashboard.
- Look at your server terminal. If you see "Received text message from..." but no "Message sent" line, your ACCESS_TOKEN may be invalid. Double-check your .env file has the permanent system user token.
- If you see no log output at all, Meta is not reaching your server. Verify the webhook URL in your Meta App Dashboard still matches the cloudflared URL (it changes every time you restart the tunnel).
Help me debug why my WhatsApp bot isn't replying to messages
✔️ Awesome, I've got everything!
Great. Double check you've saved index.js and that your server is running without errors.
ⓧ I'd like to double check the full code
Here's the full index.js at this point:
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
const VERIFY_TOKEN = process.env.VERIFY_TOKEN;
const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
const PHONE_NUMBER_ID = process.env.PHONE_NUMBER_ID;
// Health check
app.get('/', (req, res) => {
res.send('WhatsApp Bot is running.');
});
// Webhook verification (Meta sends GET to verify your endpoint)
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 === VERIFY_TOKEN) {
console.log('Webhook verified successfully.');
res.status(200).send(challenge);
} else {
console.log('Webhook verification failed.');
res.sendStatus(403);
}
});
// Receive incoming messages (Meta sends POST with message data)
app.post('/webhook', (req, res) => {
const body = req.body;
if (body.object === 'whatsapp_business_account') {
body.entry.forEach((entry) => {
entry.changes.forEach((change) => {
if (change.field === 'messages') {
const messages = change.value.messages;
if (messages) {
messages.forEach((message) => {
handleIncomingMessage(message);
});
}
}
});
});
res.sendStatus(200);
} else {
res.sendStatus(404);
}
});
// Handle incoming message and auto-reply
async function handleIncomingMessage(message) {
const from = message.from;
const type = message.type;
console.log(`Received ${type} message from ${from}`);
if (type === 'text') {
const incomingText = message.text.body.toLowerCase();
let replyText = '';
if (incomingText.includes('hello') || incomingText.includes('hi')) {
replyText = 'Hello! Welcome to our business. How can we help you today?';
} else if (incomingText.includes('hours') || incomingText.includes('open')) {
replyText = 'We are open Monday to Friday, 9 AM to 6 PM.';
} else if (incomingText.includes('help')) {
replyText = 'Sure! Please describe your question and our team will assist you shortly.';
} else {
replyText = 'Thank you for your message! Our team will get back to you shortly.';
}
await sendTextMessage(from, replyText);
}
}
// Send a text message via the Cloud API
async function sendTextMessage(to, text) {
try {
const response = await axios.post(
`https://graph.facebook.com/v25.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: to,
type: 'text',
text: { body: text }
},
{
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Message sent to ${to}: ${response.data.messages[0].id}`);
} catch (error) {
console.error('Error sending message:', error.response?.data || error.message);
}
}
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Your bot now handles live conversations. Next up, you'll learn how to reach people outside the 24-hour window using template messages, which is the compliant way to initiate conversations at scale.
Send Template Messages
Your bot can now reply to anyone who messages you first. But what happens when the 24-hour customer service window expires?
In this step, you'll add template messaging to your server. Templates are pre-approved message formats that let you initiate conversations at any time, even outside the 24-hour window. This is how businesses scale WhatsApp messaging without getting restricted.
In this step, get ready to:
- Understand why templates are required for outbound messaging.
- Add a /send-template endpoint to your server.
- Learn the compliance rules that keep your account unrestricted.
Understand why templates matter for outbound messaging
Right now, your bot can only respond after someone messages you first. The WhatsApp Cloud API enforces a 24-hour customer service window. Once that window closes, you cannot send free-form text messages to that person anymore.
Templates solve this problem. They are pre-approved message formats that let you reach people at any time, regardless of when they last messaged you.
What is a template message?
A template message is a structured message format that Meta reviews and approves before you can send it. Every new WhatsApp Business Account comes with a pre-approved hello_world template you can use immediately.
Templates exist because WhatsApp wants to prevent spam. By reviewing outbound messages before they go out, Meta ensures businesses only send relevant, expected content.
Why can't I just send text messages to anyone?
Without a template, you can only reply within 24 hours of the customer messaging you first. Service messages within that window are free and unlimited since November 2024.
Outside the window, you must use a template. Your messaging limit starts at 250 unique recipients per day and scales up automatically based on your quality rating.
Add the sendTemplateMessage function
The template message function works almost identically to your existing sendTextMessage function. The key difference is the payload structure: instead of type: 'text', you send type: 'template' with the template name and language code.
- In index.js, find the closing brace of your sendTextMessage function.
- Add the following sendTemplateMessage function below it:
// Send a template message via the Cloud API
async function sendTemplateMessage(to, templateName, languageCode) {
try {
const response = await axios.post(
`https://graph.facebook.com/v25.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
to: to,
type: 'template',
template: {
name: templateName,
language: { code: languageCode }
}
},
{
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Template sent to ${to}: ${response.data.messages[0].id}`);
} catch (error) {
console.error('Error sending template:', error.response?.data || error.message);
}
}
What does this code do?
- It POSTs to the same Graph API messages endpoint your sendTextMessage function uses.
- Instead of a text payload, it sends a template object with the template's name and language code.
- The languageCode tells Meta which localized version of the template to send (e.g. en_US for English).
Add the /send-template route
You need an HTTP endpoint so you can trigger template messages on demand. This route accepts a recipient phone number, template name, and optional language code.
- In index.js, find the app.listen block at the bottom of the file.
- Add the following route above app.listen:
// Endpoint to send a template message manually
app.post('/send-template', async (req, res) => {
const { to, template, language } = req.body;
if (!to || !template) {
return res.status(400).json({ error: 'Required fields: to, template' });
}
await sendTemplateMessage(to, template, language || 'en_US');
res.json({ status: 'Template message sent' });
});
What does this route do?
- It destructures to, template, and language from the request body.
- If to or template is missing, it returns a 400 error with a helpful message.
- It defaults language to en_US if no language code is provided.
- Save index.js.
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Restart the server by running:
node index.js
You should see Server running on port 3000 in the terminal.
Seeing a syntax error on startup?
- Check that the sendTemplateMessage function has its closing } brace before the /send-template route.
- Make sure the /send-template route is placed above app.listen, not inside another function.
Help me debug my template message code.
Test the template endpoint
Now send the pre-approved hello_world template to your own phone number. You'll need a second terminal since your server is running in the first one.
- In VS Code, click the + icon in the top-right corner of the terminal panel to open a new terminal tab.
- Send the template by running this command:
macOS
curl -X POST http://localhost:3000/send-template -H "Content-Type: application/json" -d '{"to": "[[YOUR_PHONE="your phone number with country code e.g. 15551234567"]]", "template": "hello_world", "language": "en_US"}'
Windows
curl.exe -X POST http://localhost:3000/send-template -H "Content-Type: application/json" -d "{\"to\": \"[[YOUR_PHONE=\"your phone number with country code e.g. 15551234567\"]]\", \"template\": \"hello_world\", \"language\": \"en_US\"}"
What phone number format do I use?
Use your personal phone number with the country code and no spaces, dashes, or plus sign. For example, a US number would look like 15551234567. This is the same number you added as a test recipient in Step 2.
You should see {"status":"Template message sent"} in the terminal where you ran the curl command. Check your WhatsApp on your phone. You should receive the hello_world template message.
Not receiving the template message?
- Make sure your phone number includes the country code without a plus sign or spaces.
- Check the server terminal for error output. A 400 error from Meta usually means the phone number format is wrong.
- Confirm your phone number was added as a test recipient in the Meta App Dashboard from Step 2.
Help me troubleshoot my template message.
✔️ Awesome, I've got everything!
Great. Double check you've saved index.js and your server is running without errors.
ⓧ I'd like to double check the full code
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
const VERIFY_TOKEN = process.env.VERIFY_TOKEN;
const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
const PHONE_NUMBER_ID = process.env.PHONE_NUMBER_ID;
// Health check
app.get('/', (req, res) => {
res.send('WhatsApp Bot is running.');
});
// Webhook verification (Meta sends GET to verify your endpoint)
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 === VERIFY_TOKEN) {
console.log('Webhook verified successfully.');
res.status(200).send(challenge);
} else {
console.log('Webhook verification failed.');
res.sendStatus(403);
}
});
// Receive incoming messages (Meta sends POST with message data)
app.post('/webhook', (req, res) => {
const body = req.body;
if (body.object === 'whatsapp_business_account') {
body.entry.forEach((entry) => {
entry.changes.forEach((change) => {
if (change.field === 'messages') {
const messages = change.value.messages;
if (messages) {
messages.forEach((message) => {
handleIncomingMessage(message);
});
}
}
});
});
res.sendStatus(200);
} else {
res.sendStatus(404);
}
});
// Handle incoming message and auto-reply
async function handleIncomingMessage(message) {
const from = message.from;
const type = message.type;
console.log(`Received ${type} message from ${from}`);
if (type === 'text') {
const incomingText = message.text.body.toLowerCase();
let replyText = '';
if (incomingText.includes('hello') || incomingText.includes('hi')) {
replyText = 'Hello! Welcome to our business. How can we help you today?';
} else if (incomingText.includes('hours') || incomingText.includes('open')) {
replyText = 'We are open Monday to Friday, 9 AM to 6 PM.';
} else if (incomingText.includes('help')) {
replyText = 'Sure! Please describe your question and our team will assist you shortly.';
} else {
replyText = 'Thank you for your message! Our team will get back to you shortly.';
}
await sendTextMessage(from, replyText);
}
}
// Send a text message via the Cloud API
async function sendTextMessage(to, text) {
try {
const response = await axios.post(
`https://graph.facebook.com/v25.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: to,
type: 'text',
text: { body: text }
},
{
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Message sent to ${to}: ${response.data.messages[0].id}`);
} catch (error) {
console.error('Error sending message:', error.response?.data || error.message);
}
}
// Send a template message via the Cloud API
async function sendTemplateMessage(to, templateName, languageCode) {
try {
const response = await axios.post(
`https://graph.facebook.com/v25.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
to: to,
type: 'template',
template: {
name: templateName,
language: { code: languageCode }
}
},
{
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Template sent to ${to}: ${response.data.messages[0].id}`);
} catch (error) {
console.error('Error sending template:', error.response?.data || error.message);
}
}
// Endpoint to send a template message manually
app.post('/send-template', async (req, res) => {
const { to, template, language } = req.body;
if (!to || !template) {
return res.status(400).json({ error: 'Required fields: to, template' });
}
await sendTemplateMessage(to, template, language || 'en_US');
res.json({ status: 'Template message sent' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Understand the compliance system that keeps you unrestricted
Sending templates is only half the story. Meta enforces a compliance system that determines how many people you can message and whether your account gets restricted. Understanding these rules now prevents problems when you scale.
What is the quality rating system?
Your quality rating reflects how recipients react to your messages. It is based on user feedback (blocks, reports, and mutes) over the past 7 days, weighted by recency.
- Green = High quality. Everything is working well.
- Yellow = At risk. Too many people are blocking or reporting you.
- Red = Restricted. Your messaging limit is reduced or your account is flagged.
How do messaging limits work?
Every new WhatsApp Business Account starts at 250 unique recipients per 24 hours. Your limit scales up automatically when you maintain high quality and utilize at least half of your current limit in the last 7 days.
The tiers are: 250, then 2,000, then 10,000, then 100,000, then Unlimited. To reach 2,000, you need business verification or 2,000 delivered messages to unique numbers in 30 days with high quality templates.
What are the opt-in rules?
You may only message people who explicitly opted in. Opt-in must be category-specific. For example, someone who opted in for order updates has not opted in for promotional messages.
This means you need separate consent for each type of communication you plan to send. Violating opt-in rules directly impacts your quality rating.
- Check your current quality rating by opening WhatsApp Manager in Meta Business Suite.
- Navigate to Account Tools, then Phone Numbers.
- Your quality rating is displayed next to your phone number entry.
Your bot is now fully capable of both reactive and proactive messaging. Next up, you'll take on a bonus challenge to add message logging and opt-in tracking for production readiness.
Secret mission
Add Message Logging with Opt-In Tracking
Your bot works, but you're flying blind. How many messages did you send today? Are all your contacts properly opted in? Without tracking, you won't know your quality is dropping until Meta restricts you. Add persistent logging and a compliance dashboard to stay ahead.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses free-tier services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Meta Developer App (free, no ongoing cost).
- WhatsApp Business Account (free, tied to your Meta App).
- Cloudflare Tunnel via cloudflared (free, only active while the process runs).
- Local whatsapp-bot project folder with Node.js server files.
Keep everything running
No action needed. Choose this if you are still actively building or testing your WhatsApp bot.
- Leave your Node.js server and cloudflared process running to keep the bot active.
- Your Meta Developer App and WhatsApp Business Account remain configured and ready.
- The quick tunnel URL changes each time you restart cloudflared. If you restart, you will need to update the webhook Callback URL in the Meta App Dashboard.
Pause - I'll come back to this later
Shut down running processes to free up system resources. No charges apply. You can restart anytime.
- Stop the Node.js server by pressing Ctrl+C in the terminal where it is running.
- Stop cloudflared by pressing Ctrl+C in its terminal.
- Your project files, Meta App, and WhatsApp Business Account all remain intact.
- When you return, restart the server with node index.js, start a new tunnel, and update the webhook Callback URL in Meta's dashboard with the new tunnel URL.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Delete the Meta Developer App: go to developers.facebook.com/apps, select your app, navigate to App Settings > Basic, scroll to the bottom of the page, and click Delete App. This also removes the connected WhatsApp Business Account integration.
- Uninstall cloudflared from your machine.
Windows
Run this command in PowerShell:
winget uninstall Cloudflare.cloudflared
macOS
Run this command in Terminal:
brew uninstall cloudflared
- Delete the project folder from your Desktop.
macOS
rm -rf ~/Desktop/whatsapp-bot
Windows
Remove-Item -Recurse -Force ~\Desktop\whatsapp-bot
What about the quick tunnel URL?
The quick tunnel URL you used during development is temporary. It stops working the moment you close cloudflared. No cleanup is needed for it.
For a permanent setup, you would create a named Cloudflare Tunnel with a custom domain instead of the random trycloudflare.com address.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional WhatsApp bot using the official Cloud API, connected it to Meta's infrastructure, and learned the compliance rules that keep your messaging unrestricted.
You've learned how to:
- Build a Node.js Express webhook server that receives WhatsApp messages via Meta's official Cloud API and auto-replies within the free 24-hour customer service window.
- Send template messages using the pre-approved hello_world template to initiate conversations compliantly outside the service window.
- Connect a local server to the internet using Cloudflare Tunnel and navigate the compliance system (opt-in rules, quality rating, and messaging limits) that prevents account restrictions.
- Secret Mission: Added message logging and opt-in tracking to monitor your compliance health.
Ready to quiz yourself?