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:

  1. 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.
  2. 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.
  3. Retired buttons also go to the normal pipeline, so a person sees them.
  4. 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.
  5. 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.
  6. 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 text message 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_reply component 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.id can, 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_agent and 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) and interactive.button_reply taps.
  • Keep context.id to 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.