"Where is my order?" is the question online stores answer most on WhatsApp, and it's the easiest one to automate. This tutorial builds a small Node service that does two things: when your store marks an order shipped, it sends the customer a WhatsApp template with a Where is my order? button; when they tap it, it replies with the order's current status. Anything else the customer writes goes to your team inbox as usual.
It's about 120 lines of TypeScript with no dependencies beyond Node 22, using WAPGROWAI's public API to send and its webhooks to hear about taps. The tests run the whole thing, HTTP server included, against a stubbed API, so they never send a real message.
How it fits together
-
Your store → the bot. Your store calls
/hooks/store/orderswhen an order changes. On "shipped", the bot sends theorder_shippedtemplate. -
The bot → WhatsApp.
POST /messages/sendwith the template name and the customer's name and order number. -
WhatsApp → the bot. When the customer taps Where is my order?, WAPGROWAI calls
/hooks/whatsapp/<secret>with acta_button_clickedevent. - The bot → WhatsApp. A plain-text reply with the order's status. The tap opened WhatsApp's 24-hour customer service window, so free text is allowed.
Set up WAPGROWAI
-
A template named
order_shipped, bodyHi {{1}}, your order {{2}} has shipped., with a quick-reply button Where is my order?. Leave the button's On click action as No action, so the bot answers instead of an automation. -
An API token with the
messages:sendscope: Connectors → API → Create API Token. -
A webhook under Connectors → Webhooks, subscribed to CTA Button Clicked, pointing at
https://your-server/hooks/whatsapp/<secret>, where<secret>is a long random string you also put inWEBHOOK_SECRET.
The API client
// A small client for the two calls the bot makes.
export type SendResult = { ok: true; messageId: string } | { ok: false; retry: boolean; status: number; message: string };
type Fetch = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
status: number;
json: () => Promise<any>;
}>;
export const createClient = (token: string, fetchImpl: Fetch, base = "https://api.wpautoai.com/api/public") => {
const send = async (body: Record<string, unknown>): Promise<SendResult> => {
const res = await fetchImpl(`${base}/messages/send`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (res.status >= 200 && res.status < 300) return { ok: true, messageId: json.data?.id };
// 429 and 5xx may succeed later; a 400 won't until the request changes.
return { ok: false, retry: res.status === 429 || res.status >= 500, status: res.status, message: json.message ?? "" };
};
return {
sendTemplate: (phone: string, templateName: string, body: string[]) =>
send({ type: "template", phone, template_name: templateName, parameters: { body } }),
// Free text only reaches someone who messaged in the last 24 hours. A button tap counts.
sendText: (conversationId: string, text: string) => send({ type: "text", conversation_id: conversationId, text }),
};
};
The client sorts failures into two kinds. A 429 or a 5xx might succeed later, so the caller can retry. A 400 (a template that doesn't exist, a number that isn't valid) won't, and retrying only repeats the error.
The bot
import { timingSafeEqual } from "node:crypto";
import type { createClient } from "./wapgrowai.ts";
export type Order = { id: string; phone: string; name: string; status: "placed" | "shipped" | "out_for_delivery" | "delivered"; eta?: string };
type Client = ReturnType<typeof createClient>;
export type Reply = { status: number; body?: string };
const digits = (phone: string) => phone.replace(/\D/g, "");
export const TRACK_BUTTON = "Where is my order?";
const STATUS_TEXT: Record<Order["status"], (o: Order) => string> = {
placed: (o) => `Order ${o.id} is confirmed and being packed.`,
shipped: (o) => `Order ${o.id} has shipped${o.eta ? ` and should arrive ${o.eta}` : ""}.`,
out_for_delivery: (o) => `Order ${o.id} is out for delivery today.`,
delivered: (o) => `Order ${o.id} was delivered. Reply here if anything's wrong.`,
};
export const createBot = (client: Client, webhookSecret: string) => {
const orders = new Map<string, Order>();
const notified = new Set<string>(); // `${orderId}:${status}`
const seenMessages = new Set<string>();
// 1. Your store calls this whenever an order changes.
const onOrderUpdate = async (order: Order): Promise<Reply> => {
orders.set(order.id, order);
if (order.status !== "shipped") return { status: 200 };
const key = `${order.id}:shipped`;
// Stores retry webhooks too: one "shipped" message per order.
if (notified.has(key)) return { status: 200 };
notified.add(key);
const result = await client.sendTemplate(digits(order.phone), "order_shipped", [order.name, order.id]);
if (result.ok) return { status: 200 };
if (result.retry) {
notified.delete(key); // let the store's retry try again
return { status: 503 };
}
return { status: 200, body: `not sent: ${result.message}` }; // a retry won't help; log it
};
// 2. WAPGROWAI calls this when someone taps a button. The webhook isn't
// signed, so the URL carries a long random secret.
const onWhatsAppEvent = async (pathSecret: string, event: any): Promise<Reply> => {
const a = Buffer.from(pathSecret);
const b = Buffer.from(webhookSecret);
if (a.length !== b.length || !timingSafeEqual(a, b)) return { status: 404 };
const data = event?.data ?? {};
if (event?.event !== "cta_button_clicked" || data.button_text !== TRACK_BUTTON) return { status: 200 };
// Deliveries are retried on any non-2xx response: answer each tap once.
if (seenMessages.has(data.message_id)) return { status: 200 };
seenMessages.add(data.message_id);
// The tap doesn't say which order, so answer about this customer's latest one.
const phone = digits(String(data.contact_phone ?? ""));
const latest = [...orders.values()].filter((o) => digits(o.phone) === phone).at(-1);
const text = latest
? STATUS_TEXT[latest.status](latest)
: "We couldn't find an order for this number. Someone from our team will reply here shortly.";
await client.sendText(data.conversation_id, text);
return { status: 200 };
};
return { onOrderUpdate, onWhatsAppEvent };
};
Sending once per order
Store platforms retry their webhooks, and an order can be saved as "shipped" more than once. The bot remembers orderId:shipped and sends the template once. If the send is rate-limited, it forgets the key and returns 503, so the store's own retry sends it later. If the API refuses the send outright, it returns 200 with the reason: a retry wouldn't help, so the store shouldn't keep trying.
Checking the webhook really came from WAPGROWAI
WAPGROWAI's webhooks aren't signed yet, so a request can't prove where it came from. The bot uses the next best thing: a long random secret in the URL path, compared in constant time, and a 404 for anything else. Keep the URL out of logs, and treat the payload as a hint, not proof. Here it only ever causes a reply about the caller's own latest order.
Answering each tap once
A webhook delivery is retried when your server returns an error, so the same tap can arrive again. The bot records each message_id and answers it once. In production, keep that in a database with a unique index rather than in memory.
Which order?
The tap carries the customer's number and the button text, but not which order message they tapped. The bot answers about that customer's latest order, which is right almost every time. If customers often have several open orders, use a dynamic URL button instead (https://shop.example.com/track/{{1}} with the order number) and skip the webhook for tracking entirely.
Leave everything else to people
The bot only reacts to one button. Other buttons ("Talk to a person") and anything typed go to the team inbox as normal messages, because they're conversations, not lookups.
The server
import { createServer } from "node:http";
import { createClient } from "./wapgrowai.ts";
import { createBot, type Reply } from "./bot.ts";
const client = createClient(process.env.WAPGROWAI_TOKEN!, fetch);
const bot = createBot(client, process.env.WEBHOOK_SECRET!);
const readJson = async (req: import("node:http").IncomingMessage) => {
let raw = "";
for await (const chunk of req) raw += chunk;
return JSON.parse(raw || "{}");
};
export const server = createServer(async (req, res) => {
let reply: Reply = { status: 404 };
try {
const url = new URL(req.url ?? "/", "http://localhost");
const hook = url.pathname.match(/^\/hooks\/whatsapp\/([\w-]+)$/);
// Check your store's own webhook signature here before trusting the body.
if (req.method === "POST" && url.pathname === "/hooks/store/orders") reply = await bot.onOrderUpdate(await readJson(req));
else if (req.method === "POST" && hook) reply = await bot.onWhatsAppEvent(hook[1], await readJson(req));
} catch {
reply = { status: 400 };
}
res.writeHead(reply.status, { "Content-Type": "text/plain" }).end(reply.body ?? "");
});
if (process.env.PORT) server.listen(Number(process.env.PORT));
Plain node:http, two routes, and a 404 for everything else. Run it with WAPGROWAI_TOKEN, WEBHOOK_SECRET and PORT set: node --experimental-strip-types server.ts. Put your store's own webhook signature check where the comment says. Every store platform signs its webhooks differently.
What the tests check
-
Sending: a shipped order sends
order_shippedto919820011223with["Asha", "A-1042"], to the right URL with the bearer token. - Once per order: a "placed" update sends nothing, and the store sending "shipped" twice sends one template.
- Rate limits: a 429 returns 503 to the store, and the store's retry sends the message.
- Refusals: a 400 is reported and not retried.
- Taps: after the order moves to "out for delivery", a tap gets "Order A-1042 is out for delivery today."
- Redelivery: the same tap delivered twice is answered once.
- The secret: a wrong path secret gets 404 and sends nothing.
- Hands off: "Talk to a person" and ordinary messages send nothing from the bot.
- Unknown numbers: a tap from a number with no orders gets a holding reply.
-
End to end: the real HTTP server, with
fetchstubbed, routes both webhooks and returns 404 for anything else.
Production notes
- Respond fast. Do the API call after replying to the webhook if it can take long. A slow endpoint delays your own retries and the sender's.
- Store state in a database. The in-memory maps here are for the tutorial: orders, sent keys and seen message IDs all need to survive a restart.
- Normalise phone numbers. The bot compares digits only, which works when your store saves numbers with the country code. If it doesn't, use libphonenumber with your default country.
- Check your automations. If a keyword rule or a welcome chatbot also answers every message, the customer may get two replies to a tap.
WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: send WhatsApp order updates from Google Sheets and routing WhatsApp quick-reply buttons to actions.
Top comments (0)