Skip to content

React to button taps

A template with quick-reply buttons is a question: confirm the delivery, approve the request, yes or no. The answer comes back as a WhatsApp message, and tying that message to the question it answers is the part that usually goes wrong. This flow turns it into one event that names the button and the message it replies to.

  1. Send the question with a payload on each button

    Section titled “Send the question with a payload on each button”
    const res = await fetch('https://k-message.kerneltics.com/v1/messages', {
    method: 'POST',
    headers: {
    Authorization: `Bearer ${process.env.KM_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `order-${order.id}-confirm`,
    },
    body: JSON.stringify({
    to: order.customerPhone,
    type: 'template',
    reference: `order:${order.id}`,
    bot_reply: false,
    template: {
    name: 'delivery_confirmation',
    parameters: { name: order.customerName, order: String(order.id) },
    button_parameters: { '0': 'confirm', '1': 'reschedule' },
    },
    }),
    })
    const message = await res.json()

    reference is your own id, up to 255 characters, and comes back untouched when a button is tapped. button_parameters gives each quick-reply button a payload by its position on the template: the contact sees the button’s text, you get the payload. bot_reply: false keeps the chatbot out of the answer, which is what you want when your system is the one replying.

    message.id comes back as in_reply_to.message_id, so store it if the reference alone is not enough to find the question again.

  2. Terminal window
    curl https://k-message.kerneltics.com/v1/webhooks \
    -H "Authorization: Bearer km_live_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{
    "name": "Button taps",
    "url": "https://api.example.com/hooks/kmessage",
    "events": ["button.reply"]
    }'

    Store the secret from the response; it is shown once. A webhook you already have can take button.reply in its events through PUT /v1/webhooks/{id}.

  3. The event carries what was tapped and which message it answers:

    {
    "event": "button.reply",
    "timestamp": "2026-08-20T09:20:11.804Z",
    "data": {
    "message_id": "9a8b7c6d-...",
    "contact_id": "3f2a...",
    "contact_phone": "+966500000000",
    "contact_name": "Sara",
    "whatsapp_account": "Main",
    "button": { "id": "confirm", "text": "Yes, confirm" },
    "in_reply_to": {
    "message_id": "6f1c2d3e-...",
    "whatsapp_message_id": "wamid.HBgMOTY2NTAwMDAwMDAwFQIAERgS...",
    "template_name": "delivery_confirmation",
    "reference": "order:1001",
    "sent_at": "2026-08-20T09:15:00Z"
    }
    }
    }
    case 'button.reply': {
    const ref = data.in_reply_to?.reference ?? ''
    if (!ref.startsWith('order:')) break // not one of ours
    const order = await orders.get(ref.slice('order:'.length))
    if (!order || order.confirmedAt) break // already handled, or a retry
    if (data.button.id === 'confirm') await orders.confirm(order.id)
    if (data.button.id === 'reschedule') await orders.flagForCall(order.id)
    await reply(data.contact_id, order)
    break
    }

    Three things this handler does on purpose. It checks reference before anything else, because every tap in the account arrives here, including taps on templates it has never heard of. It branches on button.id, never on button.text, because the text is whatever the template says today and the id is the payload you chose. And it treats an already-confirmed order as done rather than as an error, which is what makes a retried delivery harmless.

    in_reply_to is null when the tap cannot be tied to a message you sent through K-Message. Ignore those unless you have a use for a bare tap.

    Verify the signature and answer 200 before doing any of this, as with every event. See Receive messages.

  4. A tap is a message from the contact, so the 24-hour window is open and free text is allowed:

    async function reply(contactId, order) {
    await fetch('https://k-message.kerneltics.com/v1/messages', {
    method: 'POST',
    headers: {
    Authorization: `Bearer ${process.env.KM_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `order-${order.id}-confirm-reply`,
    },
    body: JSON.stringify({
    contact_id: contactId,
    type: 'text',
    text: { body: `Thank you. Order ${order.id} is confirmed for tomorrow.` },
    }),
    })
    }

    contact_id comes straight from the event, so there is no phone number to look up or normalise.

callback_url on the send delivers the same button.reply event to one URL, for that message only:

{
"to": "+966500000000",
"type": "template",
"reference": "order:1001",
"callback_url": "https://api.example.com/orders/1001/answer?token=9f8e7d6c",
"template": {
"name": "delivery_confirmation",
"parameters": { "name": "Sara", "order": "1001" },
"button_parameters": { "0": "confirm", "1": "reschedule" }
}
}

Nothing to register and nothing to subscribe to. The trade is that the delivery is not signed: the URL is not a configured webhook and has no secret. Put a token in the URL, as above, and treat whoever presents it the way you would treat any bearer of that token. Retries follow the webhook policy, three attempts with backoff on a 5xx or a timeout. Loopback, private and link-local hosts are refused, as they are for webhooks.

Both can be in place at once: the organization’s webhooks get the event, and so does the URL.

Without bot_reply: false, a tap reaches the chatbot like any other inbound message, and a flow or an AI agent may answer it before your system does. Set it when your system owns the reply. It applies to that one message: the tap on it, and anything the contact types as a reply to it, skip automation. They are still stored, still visible in the inbox, and still delivered to your webhooks. The rest of the conversation is unaffected.

Someone who types “yes” rather than tapping produces message.incoming, not button.reply. Subscribe to both if you need to catch that, and match on content as a fallback. The tap is the reliable path, and the reason to prefer buttons over asking for a word.