DEV Community

Cover image for Routing WhatsApp Quick-Reply Buttons to Actions: Payloads, Retries, Edits and Double Taps
WAPGROWAI for WAPGROWAI

Posted on

Routing WhatsApp Quick-Reply Buttons to Actions: Payloads, Retries, Edits and Double Taps

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;
};
Enter fullscreen mode Exit fullscreen mode

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 }] }));
Enter fullscreen mode Exit fullscreen mode

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";
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)