· 4 min · TextMeFlow Team

Build a WhatsApp menu bot in Node.js with Express — sessions and routing

A keyword bot answers one message at a time. A menu bot remembers where the customer is in a conversation — "1 for support, 2 for billing" and then a follow-up question inside that branch. That small bit of state is the difference between a bot that feels scripted and one that feels like a real front desk. This tutorial builds one in Node.js with Express, using the TextMeFlow API for send and receive.

What you need

  • A TextMeFlow account with a WhatsApp number linked via QR (getting started guide)
  • Your API key and webhook secret from the portal
  • Node.js 18+ with express installed

Step 1 — the webhook endpoint

TextMeFlow POSTs every incoming message to your webhook URL as JSON, signed with HMAC-SHA256 in the X-TextMeFlow-Signature header (sha256=<hex>). The one Express-specific trap: you must read the raw request body before any JSON-parsing middleware touches it, or the signature check will fail against re-serialized bytes. Mount express.raw() on the webhook route specifically, not globally:

import express from "express";
import crypto from "crypto";

const app = express();
const WEBHOOK_SECRET = process.env.TMF_WEBHOOK_SECRET;
const API_KEY = process.env.TMF_API_KEY;
const API_URL = "https://api.textmeflow.eu/v1/messages";

app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.body).digest("hex");
    const received = req.header("X-TextMeFlow-Signature") || "";

    const a = Buffer.from(expected);
    const b = Buffer.from(received);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body);
    await handleMessage(event);
    res.sendStatus(200);
  }
);

crypto.timingSafeEqual throws if the two buffers aren't the same length, so check that first — a mismatched length just means "wrong signature," not a crash. Full header and retry reference is in the webhook signature guide.

Step 2 — track conversation state

A Map keyed by sender number is enough for a single-process bot:

const sessions = new Map(); // phone -> { step: string }

async function handleMessage(event) {
  const from = event.from;
  const text = (event.message || "").trim().toLowerCase();
  const session = sessions.get(from) || { step: "root" };

  let reply;
  if (session.step === "root") {
    if (text === "1") {
      reply = "Support — describe your issue in one message and we'll follow up.";
      session.step = "support";
    } else if (text === "2") {
      reply = "Billing — reply with your order number.";
      session.step = "billing";
    } else {
      reply = "Hi! Reply 1 for support, 2 for billing.";
    }
  } else if (session.step === "support" || session.step === "billing") {
    reply = "Thanks, a team member will get back to you shortly.";
    session.step = "root"; // reset after the branch resolves
  }

  sessions.set(from, session);
  if (reply) await sendMessage(from, reply);
}

Two things matter for correctness here: reset step back to root once a branch resolves (otherwise the customer gets stuck), and treat any unrecognised input in root as the menu prompt rather than silence — unlike a pure keyword bot, a menu bot should always tell the customer what their options are.

Step 3 — send the reply

async function sendMessage(to, text) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ to, text }),
  });
  if (!res.ok) throw new Error(`send failed: ${res.status}`);
}

Replies count against your plan's monthly quota and per-account rate limits, so a menu bot answering real customers stays comfortably inside the free plan's 50 messages/month for testing, or Starter's 500/month in production.

Step 4 — respect the anti-spam pipeline

Two things happen automatically that your bot code doesn't need to implement: STOP (and its variants) is handled at the API level, so an opted-out recipient is blocked before your webhook even fires again, and messages sent 22:00–08:00 in the recipient's local time are deferred to 08:00 unless you set "urgent": true — which is worth doing for a billing confirmation, not for a menu reply that can wait until morning.

Step 5 — run and test

export TMF_API_KEY=... TMF_WEBHOOK_SECRET=...
node server.js

Expose the port with a tunnel (ngrok or similar) during development, set that URL as your webhook endpoint in the TextMeFlow portal, and message your own number to walk through the menu.

Scaling past one process

An in-memory Map disappears on restart and doesn't share state across multiple server instances. Once you're running more than one process — or using multi-device on the Business plan — swap it for Redis with a short TTL (a menu session that's been idle for 30 minutes should reset anyway).

Ready to wire this up against a real number? The free plan includes 50 messages a month, forever — enough to build and test the full flow. Create your free account.

Zelf WhatsApp-berichten versturen via API?

Gratis voor altijd tot 50 berichten/maand. QR scannen en binnen 5 minuten verstuur je je eerste bericht.

Gratis voor altijd