> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wsapi.chat/llms.txt
> Use this file to discover all available pages before exploring further.

# Button and list replies

> Events you receive when a contact taps a reply button or picks a list row

When a contact taps a reply button on a message sent with [`POST /messages/buttons`](/api-reference/interactive-messages), or picks a row on a list sent with [`POST /messages/list`](/api-reference/interactive-messages#send-a-list), WSAPI delivers a `message` event that says which one they chose.

## What arrives

<Tabs>
  <Tab title="Button reply">
    ```json theme={null}
    {
      "eventId": "evt_1234567890123457",
      "instanceId": "ins_ABCdefGHIjk",
      "receivedAt": "2025-05-19T00:51:44.816Z",
      "eventType": "message",
      "eventData": {
        "id": "3A5F0E9C1B2D4E6F7A80",
        "time": "2025-05-19T00:51:47.500Z",
        "chatId": "5491100000001@s.whatsapp.net",
        "sender": {
          "id": "5491100000001@s.whatsapp.net",
          "lid": "102030405060708@lid",
          "phone": "5491100000001",
          "isMe": false
        },
        "isGroup": false,
        "isStatus": false,
        "type": "button_reply",
        "text": "Confirmar",
        "buttonReply": {
          "id": "confirm",
          "text": "Confirmar"
        },
        "replyTo": {
          "id": "3EB07C7B838E3240C9E150",
          "sender": {
            "id": "5491100000000@s.whatsapp.net",
            "phone": "5491100000000",
            "isMe": true
          }
        }
      }
    }
    ```

    | Field | Description |
    | - | - |
    | `type` | Always `button_reply` |
    | `buttonReply.id` | The `id` you gave the button when you sent the message |
    | `buttonReply.text` | The button label the contact tapped |
    | `text` | The same label, for integrations that only read `text` |
    | `replyTo.id` | ID of the buttons message, as returned by the send call |
  </Tab>

  <Tab title="List reply">
    ```json theme={null}
    {
      "eventId": "evt_1234567890123458",
      "instanceId": "ins_ABCdefGHIjk",
      "receivedAt": "2025-05-19T00:52:10.204Z",
      "eventType": "message",
      "eventData": {
        "id": "3A6B1C2D3E4F5A6B7C81",
        "time": "2025-05-19T00:52:12.900Z",
        "chatId": "5491100000001@s.whatsapp.net",
        "sender": {
          "id": "5491100000001@s.whatsapp.net",
          "lid": "102030405060708@lid",
          "phone": "5491100000001",
          "isMe": false
        },
        "isGroup": false,
        "isStatus": false,
        "type": "list_reply",
        "text": "Agua",
        "listReply": {
          "id": "agua",
          "title": "Agua"
        },
        "replyTo": {
          "id": "3EB0534CDDA27C438E37C9",
          "sender": {
            "id": "5491100000000@s.whatsapp.net",
            "phone": "5491100000000",
            "isMe": true
          }
        }
      }
    }
    ```

    | Field | Description |
    | - | - |
    | `type` | Always `list_reply` |
    | `listReply.id` | The `id` you gave the row when you sent the list |
    | `listReply.title` | The row title |
    | `listReply.description` | The row description. Omitted when the row has none, as in this example |
    | `text` | The row title, for integrations that only read `text` |
    | `replyTo.id` | ID of the list message, as returned by the send call |
  </Tab>
</Tabs>

## How to use it

* **Route on the `id`, not on the text.** `buttonReply.id` and `listReply.id` are the values you set, and they stay the same whatever the label says. Labels get reworded, translated and A/B-tested — the example above is a Spanish "Confirmar" for a button whose id is `confirm`. Matching on `text` breaks the first time someone edits the copy.
* **`text` is there for everything else.** It is filled with the label or row title so that integrations reading only `text` — a chatbot, a helpdesk inbox, a log — still show something sensible. Do not rely on it for logic.
* **Correlate with `replyTo.id`.** It is the `id` returned by `POST /messages/buttons` or `POST /messages/list`. Store that ID when you send, next to whatever the message is about — the appointment, order or ticket — and look it up when the reply arrives. A button id like `confirm` tells you *what* was chosen; `replyTo.id` tells you *which* appointment it was chosen for.
* **Only reply buttons and list rows produce events.** `url`, `copy` and `call` buttons act on the contact's device — open a link, copy a code, start a call — and nothing comes back to you.
* **Replies arrive inside the `message` event**, so `message` has to be allowed in your [event filters](/basics/event-filtering).

## What can be absent

* **`listReply.description`** is omitted when the row was sent without a description.
* **`buttonReply.text` and `listReply.title`** come from the contact's WhatsApp client. Expect them, but treat them as optional.
* **`replyTo`** should always be present on a reply, since a reply is always to a message. Still, check for it rather than assume it: if it is missing, you know what was chosen but not for which message — fall back to the most recent open request in that chat, or ask again.

## Make the handler idempotent

A contact can tap a button more than once, or scroll up and tap a button on an older message. Each tap is a separate event. Webhook deliveries can also be [retried](/events/overview#delivery-and-retries). Design for it:

* Look up the state of the thing `replyTo.id` points to before acting. An appointment that is already confirmed should not be confirmed twice; one that was cancelled should not be confirmed at all.
* Decide what a second, different answer means — for example, `cancel` after `confirm` — instead of letting the last event win by accident.
* Deduplicate on `eventId` if your processing has side effects such as charges or outgoing notifications.

## Example handler

```javascript Node.js theme={null}
// Called with each parsed event from your webhook or SSE handler.
async function handleEvent(event) {
  if (event.eventType !== "message") return;
  const msg = event.eventData;

  if (msg.type === "button_reply") {
    const appointment = await db.appointments.findByMessageId(msg.replyTo?.id);
    if (!appointment) return; // not one of ours, or replyTo missing

    switch (msg.buttonReply.id) {
      case "confirm":
        if (appointment.status === "pending") await appointment.confirm();
        break;
      case "reschedule":
        await sendRescheduleOptions(msg.chatId, appointment);
        break;
      case "cancel":
        if (appointment.status !== "cancelled") await appointment.cancel();
        break;
    }
  }

  if (msg.type === "list_reply") {
    const booking = await db.bookings.findByMessageId(msg.replyTo?.id);
    if (!booking || booking.slot) return; // unknown, or a slot was already picked
    await booking.reserve(msg.listReply.id); // e.g. "slot-1200"
  }
}
```

The `db`, `appointment` and `booking` objects stand for your own data layer. The point is the shape: find the record through `replyTo.id`, switch on the `id`, and check the current state before changing it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.