Volver al blog
Desarrolladores

Mensajes interactivos de WhatsApp (botones y listas) con la API de Waspy

POST /api/v2/messages con type interactive manda botones de respuesta (hasta 3) o una lista. Son mensajes de sesión: hace falta la ventana de 24 horas. La elección vuelve en el webhook message.received.

· 13 min
Mensajes interactivos de WhatsApp (botones y listas) con la API de Waspy

Respuesta corta: un botón de respuesta o una lista se manda con POST https://api.waspytech.com/api/v2/messages, type: "interactive", scope messages:write. El objeto interactive es el de Meta y la API lo deja pasar: en el schema es un registro libre. El worker arma el envío a la Cloud API. Hace falta la ventana de 24 horas. Si está cerrada, la respuesta es 403 SERVICE_WINDOW_EXPIRED.

La referencia del POST está en messages. La API está en los planes Scale y Enterprise: Planes, la API y qué es la API de WhatsApp.

Qué acepta el servidor

CampoQué tiene que traer
phoneNumberIdUUID del canal, el de GET /channels
toTeléfono. Se normaliza a E.164
typeinteractive
interactiveObjeto. El schema no lo desarma
clientRefOpcional, hasta 256 caracteres. Vuelve en cada message.status_changed
type también acepta text, image, video, audio, document, template y reaction. Otro valor: 400 INVALID_MESSAGE_TYPE.

El worker no manda el JSON crudo. Según interactive.type:

interactive.typeQué sale a Meta
buttonTexto de body, footer si hay, y cada action.buttons[].reply (id, title) como botón reply
list (y cualquier otro type que no sea button ni cta_url)Texto de body, action.button, action.sections y footer
cta_urlTexto de body, footer, y un botón de link: action.parameters.display_text y url
header no se reenvía. Si lo mandás, queda guardado en el mensaje de Waspy y Meta no lo ve. Un producto con foto se manda aparte, type: "image", con link (URL pública) o id (media id de Meta). Subir el archivo por la API v2 no está expuesto.

Tampoco salen por este camino los mensajes de producto, de catálogo ni los Flows. Si type no es button ni cta_url, el worker lo trata como lista.

Botones: hasta 3

Los límites son los de Meta para reply buttons: hasta 3 botones, título único de hasta 20 caracteres, id de hasta 256, cuerpo de hasta 1024, footer de hasta 60. El id no lleva espacios al principio ni al final. Waspy no recuenta eso antes de encolar: si te pasás, Meta rechaza el envío y el mensaje queda failed.

curl -sS -X POST https://api.waspytech.com/api/v2/messages \
  -H "Authorization: Bearer $WASPY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-1847-pago" \
  -d '{
    "phoneNumberId": "CHANNEL_UUID",
    "to": "+5491140796637",
    "type": "interactive",
    "clientRef": "pedido-1847-pago",
    "interactive": {
      "type": "button",
      "body": { "text": "Pedido 1847. ¿Cómo seguimos?" },
      "footer": { "text": "La tienda responde en este chat" },
      "action": {
        "buttons": [
          { "type": "reply", "reply": { "id": "ya_pague", "title": "Ya pagué" } },
          { "type": "reply", "reply": { "id": "necesito_ayuda", "title": "Necesito ayuda" } }
        ]
      }
    }
  }'

La respuesta 201 trae data.status: "queued" y data.waMessageId: null. El wamid llega después, en el webhook message.status_changed cuando pasa a sent, o con GET /api/v2/messages/:id. El mismo patrón está en clientRef.

Idempotency-Key (hasta 256 caracteres) evita un duplicado si reintentás este POST. La clave vive 24 horas. Una repetición con la misma clave devuelve la respuesta guardada y el header Idempotency-Replayed: true. Dos pedidos en paralelo con la misma clave: 409 IDEMPOTENCY_CONFLICT.

Lista: talles o envíos

Meta documenta la lista en interactive list messages: un botón de hasta 20 caracteres, hasta 10 secciones, hasta 10 filas en total (no 10 por sección), título de fila hasta 24, descripción hasta 72, id de fila hasta 200, título de sección hasta 24. Esa página pone el cuerpo en 4096 caracteres. La guía general de interactivos todavía dice 1024 para el cuerpo de la lista. Si un cuerpo largo rebota, acortalo.

curl -sS -X POST https://api.waspytech.com/api/v2/messages \
  -H "Authorization: Bearer $WASPY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-1847-talle" \
  -d '{
    "phoneNumberId": "CHANNEL_UUID",
    "to": "+5491140796637",
    "type": "interactive",
    "interactive": {
      "type": "list",
      "body": { "text": "La remera negra está en estos talles." },
      "action": {
        "button": "Ver talles",
        "sections": [
          {
            "title": "Remera negra",
            "rows": [
              { "id": "talle_s", "title": "S", "description": "Queda justo" },
              { "id": "talle_m", "title": "M", "description": "El más pedido" },
              { "id": "talle_l", "title": "L", "description": "Holgado" }
            ]
          }
        ]
      }
    }
  }'

El título del botón (“Ver talles”) tiene que entrar en 20 caracteres. “Elegí el método de envío” no entra: son 25. “Ver envíos” sí.

Un tercer tipo que el worker sí manda es cta_url: un solo botón que abre una URL. Sigue siendo mensaje de sesión.

{
  "type": "cta_url",
  "body": { "text": "El seguimiento de Andreani está en este link." },
  "action": {
    "name": "cta_url",
    "parameters": {
      "display_text": "Ver seguimiento",
      "url": "https://www.andreani.com/envio/360000012345678"
    }
  }
}

La ventana de 24 horas

dispatch exige ventana abierta para todo lo que no sea plantilla. Sin serviceWindowExpiresAt, o con la fecha vencida, el POST ni encola: 403 SERVICE_WINDOW_EXPIRED. Para mirarla antes, GET /api/v2/conversations/:id y leé serviceWindowExpiresAt. Si el CRM solo tiene el teléfono, el id sale de resolve.

Fuera de la ventana, el camino es una plantilla ya aprobada (type: "template"). Crear esa plantilla por REST no está en la API. Se crea en el panel, o con la herramienta create_template del MCP (templates:write), que la manda a revisión de Meta y no le escribe a nadie.

Mientras la persona está en la ventana, el botón es un mensaje de servicio. En Argentina, desde el 1 de octubre de 2026, cada número tiene 1.000 por mes. Desde el 1.001, alrededor de ARS 37,68 (US$0,026) por mensaje entregado. Lo que la persona toca no se cobra: es un entrante. Fuentes: rate card y plataforma.

Node: talle, envío, “ya pagué”

const BASE = 'https://api.waspytech.com/api/v2';

type Interactive =
  | {
      type: 'button';
      body: { text: string };
      footer?: { text: string };
      action: { buttons: Array<{ type: 'reply'; reply: { id: string; title: string } }> };
    }
  | {
      type: 'list';
      body: { text: string };
      action: {
        button: string;
        sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>;
      };
    };

export async function sendInteractive(input: {
  phoneNumberId: string;
  to: string;
  interactive: Interactive;
  idempotencyKey: string;
  clientRef?: string;
}) {
  const res = await fetch(BASE + '/messages', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.WASPY_API_KEY,
      'Content-Type': 'application/json',
      'Idempotency-Key': input.idempotencyKey,
    },
    body: JSON.stringify({
      phoneNumberId: input.phoneNumberId,
      to: input.to,
      type: 'interactive',
      interactive: input.interactive,
      clientRef: input.clientRef,
    }),
  });
  if (res.status === 403) {
    throw new Error('ventana cerrada: hace falta una plantilla');
  }
  if (!res.ok) throw new Error('messages ' + res.status);
  return res.json() as Promise<{ data: { id: string; status: string } }>;
}

Tres casos de una tienda de indumentaria en Argentina, todos dentro de la ventana:

  1. Talle. Lista con las filas que el catálogo tiene de verdad. Si el M no está, no lo pongas.
  2. Envío. Botones “Andreani” y “Retiro en el local”, o una lista si hay más de tres opciones.
  3. Pago. Botones “Ya pagué” y “Necesito ayuda”. “Ya pagué” no marca el pedido: abre el paso en el que una persona mira el banco. El comprobante por foto está en comprobantes falsos.

El webhook cuando elige

La persona toca un botón y Meta manda un entrante. Waspy lo persiste con type: "interactive" y el contenido que vino de Meta, y emite message.received. El alta del webhook está en webhooks y la firma, en verificar la firma.

Un botón llega así en data.content:

{
  "type": "button_reply",
  "button_reply": { "id": "ya_pague", "title": "Ya pagué" }
}

Una fila de la lista:

{
  "type": "list_reply",
  "list_reply": { "id": "talle_m", "title": "M", "description": "El más pedido" }
}

El id es el que pusiste vos. El título es el texto que vio la persona. Con eso el sistema puede anotar “eligió M” o derivar “Necesito ayuda” a alguien del equipo. Mostrar “escribiendo…” mientras armás la respuesta es otro POST, en typing y leído. Si una persona del panel ya contestó, la señal para frenar tu flujo es origin: "panel", en handoff.

Para muchos avisos iguales, el lote es POST /api/v2/messages/batch (hasta 100). Un interactivo de un chat en vivo va por el POST de a uno: el lote usa prioridad de campaña. El detalle está en mensajes en lote y en rate limits.

Diez días de prueba, sin tarjeta, en el registro.

Tu equipo atendiendo por WhatsApp en minutos

CRM facil pero potente. En 10 minutos tenes a tu equipo trabajando — no necesitas hablar con nadie.