Quick-reply buttons on WhatsApp templates ("Confirm", "Reschedule", "Pay deposit") are easy to send and easy to tap. Making each tap do something is where the design work is: knowing a tap from a typed word, keeping old messages' buttons working after the template changes, not sending two payment links when someone taps twice, and not letting a bot grab a chat a person is handling. This post builds a small button router in TypeScript, with tests for each case, and says where our own implementation got one of them wrong.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
What a tap looks like
A tap on a template's quick-reply button arrives as an inbound message with type: "button". Meta's webhook reference describes it: a button object with text and payload, and a context.id that is the ID of the message containing the button. A reply button on an interactive (non-template) message arrives differently, as type: "interactive" with an interactive.button_reply carrying an id and a title. A customer who types "Confirm" sends type: "text", which is not a tap at all.
// The parts of an inbound WhatsApp message a button router needs.
export type Tap = {
wamid: string;
from: string;
payload: string;
label: string;
// ID of the message whose button was tapped, when WhatsApp sends it.
tappedMessageId?: string;
};
type InboundMessage = {
id: string;
from: string;
type: string;
context?: { id?: string };
button?: { payload?: string; text?: string };
interactive?: { type?: string; button_reply?: { id?: string; title?: string } };
};
// Template quick replies arrive as type "button"; interactive reply buttons as
// type "interactive". Anything typed is "text" and is never a tap.
export const extractTap = (m: InboundMessage): Tap | null => {
if (m.type === "button" && m.button?.payload) {
return { wamid: m.id, from: m.from, payload: m.button.payload, label: m.button.text ?? "", tappedMessageId: m.context?.id };
}
const reply = m.interactive?.type === "button_reply" ? m.interactive.button_reply : undefined;
if (m.type === "interactive" && reply?.id) {
return { wamid: m.id, from: m.from, payload: reply.id, label: reply.title ?? "", tappedMessageId: m.context?.id };
}
return null;
};
Two things follow:
- Match on the payload, never the label. The label is what the customer sees and can type. The payload is a value you control. If a typed "confirm" should also do something, that's a keyword rule, a separate decision.
-
Keep
context.id. It tells you which message was tapped, which is what you need to stop a repeated tap from repeating a side effect.
Payloads: set them yourself
You can attach your own payload to each quick-reply button when you send the template, as a button component with sub_type: "quick_reply", the button's index, and a payload parameter. When you don't, don't count on the payload being anything but the label: Meta's own webhook example shows the same text in both.
We generate an opaque payload per button action (tba_ plus random hex) and store what it should do. The prefix makes our payloads recognisable, so a payload from another tool or an older template falls through to normal handling instead of being looked up.
The registry: payloads must outlive edits
import { randomBytes } from "node:crypto";
export type ActionType = "send_message" | "send_template" | "start_chatbot" | "send_payment_request";
export type ButtonAction = {
payload: string;
templateId: string;
buttonIndex: number;
type: ActionType;
config: Record<string, unknown>;
// Side effects that must not repeat for the same sent message, such as a payment link.
oncePerMessage: boolean;
active: boolean;
};
export type ButtonInput = { index: number; type: ActionType; config: Record<string, unknown>; oncePerMessage?: boolean };
export const PAYLOAD_PREFIX = "tba_";
const newPayload = () => `${PAYLOAD_PREFIX}${randomBytes(12).toString("hex")}`;
// Saving a template must not change the payloads of buttons that still exist:
// messages already sent carry the old payload and must keep working.
export const saveTemplateButtons = (store: Map<string, ButtonAction>, templateId: string, inputs: ButtonInput[]) => {
const existing = [...store.values()].filter((a) => a.templateId === templateId);
const byIndex = new Map(existing.map((a) => [a.buttonIndex, a]));
for (const input of inputs) {
const payload = byIndex.get(input.index)?.payload ?? newPayload();
store.set(payload, {
payload,
templateId,
buttonIndex: input.index,
type: input.type,
config: input.config,
oncePerMessage: input.oncePerMessage ?? input.type === "send_payment_request",
active: true,
});
}
// Removed buttons are retired, not deleted, so a late tap is recognised.
const kept = new Set(inputs.map((i) => i.index));
for (const a of existing) if (!kept.has(a.buttonIndex)) store.set(a.payload, { ...a, active: false });
};
// The payloads to attach, by button index, when this template is sent.
export const payloadsForSend = (store: Map<string, ButtonAction>, templateId: string) =>
[...store.values()]
.filter((a) => a.templateId === templateId && a.active)
.sort((a, b) => a.buttonIndex - b.buttonIndex)
.map((a) => ({ type: "button", sub_type: "quick_reply", index: String(a.buttonIndex), parameters: [{ type: "payload", payload: a.payload }] }));
This is the part we got wrong. In WAPGROWAI, saving a template deletes its button actions and recreates them with new payloads. Messages already sent still carry the old payloads, so after any edit, taps on those messages stop matching: no action runs, and the tap goes through the normal pipeline like any other message. Nothing errors, so nobody notices until a customer says the button "didn't work".
The fix is in saveTemplateButtons: a button that still exists keeps its payload, and only new buttons get new ones. A removed button is retired, not deleted, so a late tap on an old message is recognised and sent to the inbox, where a person can answer it.
One trade-off: this keys a payload to the button's position. If someone swaps "Confirm" and "Reschedule", old messages' first button now runs the new first action. If your editor allows reordering, key by a stable button ID that moves with the button, not by its index.
The router
import { extractTap } from "./taps.ts";
import { PAYLOAD_PREFIX, type ButtonAction } from "./registry.ts";
export type Outcome =
| "duplicate_webhook"
| "not_a_tap"
| "not_ours"
| "retired_button"
| "already_done"
| "left_for_agent"
| "ran";
export type RouterDeps = {
actions: Map<string, ButtonAction>;
seenWamids: Set<string>;
doneOnce: Set<string>;
assignedToPerson: (customer: string) => boolean;
runAction: (action: ButtonAction, customer: string) => Promise<void>;
// The normal pipeline: keyword rules, an active chatbot, the inbox.
fallback: (message: unknown) => Promise<void>;
reply: (customer: string, text: string) => Promise<void>;
};
export const routeInbound = async (deps: RouterDeps, message: Parameters<typeof extractTap>[0]): Promise<Outcome> => {
// WhatsApp retries webhooks; one tap must run its action once.
if (deps.seenWamids.has(message.id)) return "duplicate_webhook";
deps.seenWamids.add(message.id);
const tap = extractTap(message);
if (!tap) return deps.fallback(message).then(() => "not_a_tap");
const action = tap.payload.startsWith(PAYLOAD_PREFIX) ? deps.actions.get(tap.payload) : undefined;
// Another tool's button, or one without an action: treat it like any message.
if (!action) return deps.fallback(message).then(() => "not_ours");
if (!action.active) return deps.fallback(message).then(() => "retired_button");
if (action.oncePerMessage && tap.tappedMessageId) {
const key = `${tap.tappedMessageId}:${action.payload}`;
if (deps.doneOnce.has(key)) {
await deps.reply(tap.from, "We've already sent this. Scroll up to find it.");
return "already_done";
}
deps.doneOnce.add(key);
}
// A person who has taken the chat owns it; a bot must not take it back.
if (action.type === "start_chatbot" && deps.assignedToPerson(tap.from)) {
return deps.fallback(message).then(() => "left_for_agent");
}
// The button wins: keyword rules don't also fire for this tap.
await deps.runAction(action, tap.from);
return "ran";
};
The order of checks is the design:
- Duplicate webhooks first. Meta retries deliveries, so the same tap can arrive twice with the same message ID. Record the ID, and in production put a unique index on it.
- Not a tap, or not ours: hand the message to the normal pipeline (keyword rules, an active chatbot, the inbox). A router that swallows everything it doesn't recognise turns buttons from other tools into silence.
- Retired buttons also go to the normal pipeline, so a person sees them.
- Once-per-message actions. A customer can tap the same button again, and for most actions that's fine: "Thanks, you're booked" twice is harmless. A payment request isn't: each tap would create another payment link. Key the "already done" check on the tapped message's ID plus the payload, so a new booking message gets its own link. Here the key is recorded before the action runs, which blocks a fast double tap; if the action fails, remove the key or store the failure so the customer can try again.
- People before bots. If a person has taken over the chat, a "Start chatbot" button shouldn't take it back. The tap goes to the inbox instead.
- The button wins. When a tap has an action, keyword rules don't also run for that message. Otherwise a rule listening for "confirm" fires alongside the button's own reply, and the customer gets two answers.
What the tests check
-
Parsing: template and interactive taps are read; a
textmessage is not a tap. - Typed text: "Confirm" typed by hand goes to the normal pipeline, and no action runs.
- Precedence: a tap runs its action, and the fallback (keyword rules) doesn't also run.
- Webhook retries: the same message ID twice runs the action once.
- Payment links: a second tap on "Pay" for the same message replies "already sent" instead of creating a second link; a tap on a new booking message creates its own.
- Harmless repeats: a second tap on "Confirm" sends the thank-you again.
- Edits: after saving the template again, button 0 keeps its payload, and a tap on a message sent before the edit still runs.
- Removed buttons: a tap on a button that no longer exists goes to the inbox.
- Human ownership: "Start chatbot" on a chat a person has taken over goes to the inbox instead.
- Foreign payloads: a payload without our prefix falls through.
-
Sending: each active button gets a
quick_replycomponent with its payload, by index.
Edge cases worth deciding
-
Payloads per send, not per template. A per-template payload can't tell you which campaign or recipient a tap came from.
context.idcan, if you store the WhatsApp message ID of every send. That also lets you report taps per campaign. - Actions outside the 24-hour window. A tap is a customer message, so it opens the customer service window, and the action can reply with free-form text. A follow-up that runs later, such as a reminder the next day, may need a template again.
- Opt-outs. A "Stop offers" button should unsubscribe the contact through the same path as a typed STOP, so both are counted and respected in the same place.
-
Logging. Record every tap with its outcome (
ran,already_done,left_for_agentand so on). "The button didn't work" is answerable in seconds when you can see the outcome.
Checklist
- Match taps on a payload you set, never on the label.
- Handle both
button(template) andinteractive.button_replytaps. - Keep
context.idto know which message was tapped. - Keep payloads stable across template edits; retire removed buttons instead of deleting them.
- Dedupe webhook retries by message ID.
- Make side effects like payment links once per tapped message.
- Don't let a bot take a chat back from a person.
- Let a tap's action take precedence over keyword rules.
- Fall through, don't swallow, for payloads you don't recognise.
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: building a rules engine for WhatsApp automation and handling WhatsApp Cloud API webhooks in Node.js.
Top comments (0)