Interactivos
Un interactivo es un mensaje con algo que tocar: botones (responder, copiar un código, abrir un enlace, llamar o un pago PIX), una lista de opciones o un carrusel de tarjetas. Las encuestas son su pariente: el contacto vota y el motor cuenta. Las respuestas y los votos llegan como eventos propios, correlacionados con el mensaje que los originó, para que tu app sepa qué eligió cada contacto sin interpretar textos.
Requisitos
Sección titulada «Requisitos»| Operación | Scope |
|---|---|
POST /v1/instances/{id}/messages con tipo: interactive o tipo: poll |
messages:send |
GET /v1/instances/{id}/interactive/{provider_id} (seguimiento) |
messages:read |
GET /v1/instances/{id}/interactive-responses (respuestas) |
messages:read |
GET /v1/instances/{id}/polls/{provider_id} (resultado de una encuesta) |
messages:read |
GET /v1/instances/{id}/events (los eventos de respuesta) |
events:read |
Botones
Sección titulada «Botones»tipo: interactive con interactivo.variante: buttons. cuerpo es obligatorio; encabezado y
pie son opcionales. Los botones son uno de estos grupos (no se mezclan):
- hasta 3 de
accion: reply(responder), cada uno con suid(único en el mensaje, hasta 200 caracteres) y sutexto; - hasta 2 entre
copy(copiarcodigo),url(abrir un enlacehttp(s)) ycall(abrir el marcador contelefono; no es una llamada del motor); - un botón
pix, conpix.comercio,pix.claveypix.tipo_clave(phone,email,cpf,cnpjorandom). Es información de pago, no una pasarela: el motor nunca ve un pago.
Solo reply produce una respuesta. Un clic en copiar, en un enlace, en llamar o en PIX nunca
llega: WhatsApp no lo informa, y el motor no lo inventa.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: confirmar-entrega-42" \ -d '{ "destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "interactive", "interactivo": { "variante": "buttons", "encabezado": "Tu pedido 42", "cuerpo": "¿Confirmas la entrega para mañana?", "pie": "Tienda Centro", "botones": [ {"accion": "reply", "id": "confirmar-42", "texto": "Sí, confirmo"}, {"accion": "reply", "id": "cambiar-42", "texto": "Cambiar fecha"} ] } }'import motor "github.com/projectx27/motor-whatsapp/sdk/go"
c := motor.Nuevo("https://motor.ejemplo.com", apiKey)i := motor.Botones("¿Confirmas la entrega para mañana?", motor.Responder("confirmar-42", "Sí, confirmo"), motor.Responder("cambiar-42", "Cambiar fecha"),)i.Encabezado, i.Pie = "Tu pedido 42", "Tienda Centro"
cmd, err := c.EnviarInteractivo(ctx, instanciaID, "confirmar-entrega-42", motor.EnvioInteractivo{ Destino: motor.Destino{Telefono: "+NUMERO_DEL_CONTACTO"}, Interactivo: i,})if err != nil { return err // un *motor.Error: Codigo() == "interactivo_invalido" si no cumple los límites}fmt.Println(cmd.ID, cmd.Estado) // … en_colaconst res = await fetch(`https://motor.ejemplo.com/v1/instances/${instanciaId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'confirmar-entrega-42', }, body: JSON.stringify({ destino: { telefono: '+NUMERO_DEL_CONTACTO' }, tipo: 'interactive', interactivo: { variante: 'buttons', encabezado: 'Tu pedido 42', cuerpo: '¿Confirmas la entrega para mañana?', pie: 'Tienda Centro', botones: [ { accion: 'reply', id: 'confirmar-42', texto: 'Sí, confirmo' }, { accion: 'reply', id: 'cambiar-42', texto: 'Cambiar fecha' }, ], }, }),});const comando = await res.json(); // 202: { command_id, type: "message.interactive", … }{ "command_id": "5c2e7a10-0000-4000-8000-0000000000c6", "instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1", "idempotency_key": "confirmar-entrega-42", "type": "message.interactive", "generation": 1, "status": "en_cola", "created_at": "2026-10-24T15:30:00Z", "updated_at": "2026-10-24T15:30:00Z"}El SDK de Go trae los atajos motor.Responder, motor.CopiarCodigo, motor.AbrirEnlace,
motor.Llamar y motor.BotonPIX para cada botón, y motor.Botones, motor.Lista y
motor.Carrusel para cada variante.
variante: list: un botón (boton_lista, hasta 20 caracteres) que abre de 1 a 10 secciones, con
10 filas en total como máximo. Cada fila lleva id (único en la lista) y titulo, y puede
llevar descripcion. El titulo de cada sección es obligatorio si hay más de una.
Con respaldo_texto: true, cuando WhatsApp acepta la lista el motor manda además al mismo chat
un texto con el cuerpo y las filas numeradas («1. 09:00», «2. 11:00»…). Si alguien de ese chat
contesta solo con uno de esos números en las 24 horas siguientes, llega su
message.received normal y, además, un interactive.response con formato: texto, via: texto y
la fila correlacionada. Cuenta la última lista con respaldo aceptada en ese chat.
{ "destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "interactive", "interactivo": { "variante": "list", "encabezado": "Horarios", "cuerpo": "Elige un horario para tu cita", "boton_lista": "Ver horarios", "respaldo_texto": true, "secciones": [ {"titulo": "Mañana", "filas": [ {"id": "h-0900", "titulo": "09:00", "descripcion": "Sede Centro"}, {"id": "h-1100", "titulo": "11:00"} ]}, {"titulo": "Tarde", "filas": [{"id": "h-1600", "titulo": "16:00"}]} ] }}Carruseles
Sección titulada «Carruseles»variante: carousel: de 2 a 10 tarjetas. Cada tarjeta lleva cuerpo (hasta 160 caracteres),
pie opcional, de 1 a 2 botones (reply, copy, url o call; PIX no va en un carrusel) y un
medio.media_id opcional, una subida de esta instancia ya lista (ver
Multimedia): una imagen JPEG o PNG o un vídeo MP4. O todas las tarjetas llevan
medio o ninguna, y todas del mismo tipo (imágenes o vídeos). El motor sube cada medio a WhatsApp dentro del mismo envío.
{ "destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "interactive", "interactivo": { "variante": "carousel", "cuerpo": "Nuestros planes", "tarjetas": [ {"medio": {"media_id": "0b9d6c7e-0000-4000-8000-0000000000f4"}, "cuerpo": "Plan básico", "botones": [{"accion": "reply", "id": "plan-basico", "texto": "Lo quiero"}]}, {"medio": {"media_id": "0b9d6c7e-0000-4000-8000-0000000000f5"}, "cuerpo": "Plan completo", "botones": [{"accion": "reply", "id": "plan-completo", "texto": "Lo quiero"}, {"accion": "url", "texto": "Detalles", "url": "https://crm.ejemplo.com/planes"}]} ] }}Native flows
Sección titulada «Native flows»Los tres se arman como mensajes native flow de WhatsApp (estrategia mw10.2, que queda en el
seguimiento de cada envío como version): reply sale como quick_reply, copy como cta_copy,
url como cta_url, call como cta_call, pix como payment_info, y la lista como un
single_select. No hay un envío de native flow libre: solo estas variantes. Las respuestas, en
cambio, llegan en el formato que elige el teléfono que responde (ver
Respuestas).
Límites del contrato
Sección titulada «Límites del contrato»El motor los revisa al aceptar; un interactivo que no cumple es 422 interactivo_invalido, con
el campo en errors (por ejemplo, body.interactivo.botones[1].url).
| Campo | Límite |
|---|---|
cuerpo |
Obligatorio, hasta 1024 caracteres (160 en una tarjeta) |
encabezado, pie |
Hasta 60 caracteres |
botones[].texto, boton_lista |
Hasta 20 caracteres |
botones[].id, filas[].id |
Hasta 200 caracteres, únicos en el mensaje |
secciones |
De 1 a 10, con 10 filas en total; título de sección hasta 24 |
filas[].titulo / filas[].descripcion |
Hasta 24 / hasta 72 caracteres |
tarjetas |
De 2 a 10, cada una con 1 o 2 botones |
pix.clave |
En el formato de su tipo_clave (CPF y CNPJ con sus dígitos verificadores) |
Ningún texto admite caracteres de control.
Encuestas
Sección titulada «Encuestas»tipo: poll con pregunta (hasta 255 caracteres), opciones (de 2 a 12, distintas, de hasta 100
caracteres) y multiple (true: se puede elegir más de una). El motor guarda sellado el secreto de
la encuesta: un reintento manda la misma y los votos se pueden descifrar aunque el motor se
reinicie.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: encuesta-dia-entrega-42" \ -d '{ "destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "poll", "pregunta": "¿Qué día te queda mejor?", "opciones": ["Lunes", "Miércoles", "Viernes"] }'
# El resultado agregado, por el provider_id del mensajecurl https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/polls/PROVIDER_MESSAGE_ID \ -H "Authorization: Bearer TU_API_KEY"// El SDK de Go no trae la encuesta: es HTTP directo.cuerpo, _ := json.Marshal(map[string]any{ "destino": map[string]string{"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "poll", "pregunta": "¿Qué día te queda mejor?", "opciones": []string{"Lunes", "Miércoles", "Viernes"},})req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/instances/"+instanciaID+"/messages", bytes.NewReader(cuerpo))req.Header.Set("Authorization", "Bearer "+c.Token)req.Header.Set("Content-Type", "application/json")req.Header.Set("Idempotency-Key", "encuesta-dia-entrega-42")res, err := c.HTTP.Do(req) // 202 con el comando message.poll
// Cuando sale, el provider_message_id del comando es el de la encuesta.cmd, err := c.Comando(ctx, comandoID)fmt.Println(cmd.ProviderMessageID)await fetch(`https://motor.ejemplo.com/v1/instances/${instanciaId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'encuesta-dia-entrega-42', }, body: JSON.stringify({ destino: { telefono: '+NUMERO_DEL_CONTACTO' }, tipo: 'poll', pregunta: '¿Qué día te queda mejor?', opciones: ['Lunes', 'Miércoles', 'Viernes'], }),});
const resultado = await ( await fetch(`https://motor.ejemplo.com/v1/instances/${instanciaId}/polls/${providerId}`, { headers: { Authorization: `Bearer ${apiKey}` }, })).json();{ "provider_id": "PROVIDER_MESSAGE_ID", "conocida": true, "pregunta": "¿Qué día te queda mejor?", "seleccionables": 1, "opciones": [ { "opcion": "Lunes", "votos": 0 }, { "opcion": "Miércoles", "votos": 1 }, { "opcion": "Viernes", "votos": 0 } ], "votantes": 1, "sin_descifrar": 0}Cuenta el voto vigente de cada votante: un cambio de voto reemplaza el anterior, y quitarlo lo
deja vacío. seleccionables es 1 (una opción) o 0 (varias). Un voto que no se pudo descifrar se
cuenta en sin_descifrar, nunca como una opción inventada. Una encuesta que llega (creada por
el contacto) también se cuenta: su message.received trae contenido.tipo: encuesta y
contenido.encuesta con pregunta, opciones y seleccionables. Si llegan votos de una encuesta
que el motor no vio crear, conocida es false y solo se cuentan los votantes.
El comando y su seguimiento
Sección titulada «El comando y su seguimiento»El type del comando es message.interactive o message.poll, y sigue el ciclo de cualquier envío:
message.sent cuando WhatsApp lo acepta, message.updated con los recibos y command.failed o
command.ambiguous si no (ver Comandos asíncronos y eventos).
Para un interactivo, GET /v1/instances/{id}/interactive/{provider_id} separa lo que el motor
sabe de lo que no:
| Campo | Valores | Qué dice |
|---|---|---|
aceptacion |
pendiente, aceptado, rechazado, no_salio, desconocido |
Si el servidor de WhatsApp lo aceptó (desconocido si quedó ambiguo). |
recibo |
no_observado, entregado, leido |
El mejor recibo que llegó. |
render |
desconocido |
Siempre: el motor no puede saber si el teléfono lo dibujó. |
respuesta |
no_observada, observada, no_observable |
observada solo con una respuesta real; no_observable si solo lleva copy, url, call o pix. |
Trae además respuestas (los event_id de sus interactive.response), acciones (cada botón o
fila, con su accion, id, titulo, seccion o tarjeta), variante, version y aviso.
curl https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/interactive/PROVIDER_MESSAGE_ID \ -H "Authorization: Bearer TU_API_KEY"seg, err := c.SeguimientoInteractivo(ctx, instanciaID, providerID)if err != nil { return err}fmt.Println(seg.Aceptacion, seg.Recibo, seg.Respuesta) // aceptado leido observadaconst seg = await ( await fetch(`https://motor.ejemplo.com/v1/instances/${instanciaId}/interactive/${providerId}`, { headers: { Authorization: `Bearer ${apiKey}` }, })).json();console.log(seg.aceptacion, seg.recibo, seg.respuesta);Respuestas correlacionadas
Sección titulada «Respuestas correlacionadas»Cada respuesta deja un evento durable, deduplicado por su provider_id:
| Evento | Cuándo |
|---|---|
interactive.response |
Llegó una respuesta (un botón, una fila, una tarjeta o el número del respaldo de texto). |
interactive.unknown |
Llegó algo con forma de respuesta que el motor no pudo leer; trae motivo y crudo (saneado y acotado). |
interactive.response.updated |
Cambió la correlación de una respuesta ya publicada (ver abajo). Nunca es una segunda respuesta. |
poll.vote |
Un voto de una encuesta, con el resultado agregado. |
{ "chat": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] }, "autor": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] }, "provider_id": "PROVIDER_MESSAGE_ID_DE_LA_RESPUESTA", "from_me": false, "formato": "buttons_response", "mensaje": { "provider_id": "PROVIDER_MESSAGE_ID", "command_id": "5c2e7a10-0000-4000-8000-0000000000c6" }, "accion": { "id": "confirmar-42", "titulo": "Sí, confirmo", "accion": "reply" }, "correlacion": "correlacionada", "momento": "2026-10-24T15:31:10Z"}formatoes el que mandó el teléfono:buttons_response,list_response,template_button_reply,native_flow(connombre_flujoyparametros),texto(el respaldo de una lista) odesconocida.acciontrae elidy eltituloelegidos y, si se correlacionó, laacciondel interactivo (replyolist_row) con suseccion(una fila) o sutarjeta(un carrusel, desde 0).indicees el botón, si el formato lo informa.mensajees el mensaje al que responde, con elcommand_iddel envío si se correlacionó, y suautoren un grupo.correlacion:
correlacion |
Qué significa |
|---|---|
correlacionada |
Responde a un interactivo de esta instancia y de este chat, con una acción que existe. |
accion_desconocida |
El mensaje es de la instancia, pero el id elegido no está entre sus acciones. |
sin_mensaje |
No responde a un interactivo que esta instancia haya mandado (por ejemplo, otra instancia del mismo número). |
otro_chat |
El interactivo es de la instancia, pero de otro chat. |
otro_autor |
El contexto dice que el mensaje citado es de otro autor, no de esta instancia. |
no_respondible |
Responde a un interactivo de la instancia que no tiene nada que responder (solo copy, url, call o pix). |
desconocida |
No se pudo leer: llegó como interactive.unknown. |
otro_chat y otro_autor se revisan solos: si después la instancia comprueba el alias PN/LID que
faltaba, la misma respuesta (mismo response_id y mismo event_id original, en
response_event_id) se vuelve a correlacionar y llega interactive.response.updated con
correlacion_anterior.
Responder citando un interactivo con un texto normal no es una respuesta interactiva: llega como
message.received con su cita.
{ "chat": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": [] }, "votante": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": [] }, "provider_id": "PROVIDER_MESSAGE_ID_DEL_VOTO", "encuesta": "PROVIDER_MESSAGE_ID", "from_me": false, "descifrado": true, "opciones": ["Miércoles"], "vigente": true, "momento": "2026-10-24T15:33:00Z", "resultado": { "provider_id": "PROVIDER_MESSAGE_ID", "conocida": true, "pregunta": "¿Qué día te queda mejor?", "seleccionables": 1, "opciones": [{ "opcion": "Lunes", "votos": 0 }, { "opcion": "Miércoles", "votos": 1 }, { "opcion": "Viernes", "votos": 0 }], "votantes": 1, "sin_descifrar": 0 }}opciones vacío es un voto quitado. vigente: false es un voto más viejo que el que ya estaba:
llega, pero no cambia el resultado.
Listar las respuestas
Sección titulada «Listar las respuestas»GET /v1/instances/{id}/interactive-responses lista las respuestas, las más nuevas primero, con
next_cursor y has_more. Filtros: provider_id (las de un interactivo), correlacion, cursor y
limite.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/interactive-responses?provider_id=PROVIDER_MESSAGE_ID" \ -H "Authorization: Bearer TU_API_KEY"p, err := c.RespuestasInteractivas(ctx, instanciaID, providerID, "", 50)if err != nil { return err}for _, r := range p.Respuestas { fmt.Println(r.Correlacion, r.Accion.ID) // correlacionada confirmar-42}const url = new URL(`https://motor.ejemplo.com/v1/instances/${instanciaId}/interactive-responses`);url.searchParams.set('provider_id', providerId);const { respuestas, next_cursor, has_more } = await ( await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })).json();Errores comunes
Sección titulada «Errores comunes»| HTTP | Código | Cuándo |
|---|---|---|
| 404 | medio_no_encontrado |
El medio de una tarjeta no existe en esta instancia (o es de otra): el campo va en errors[].location (por ejemplo, body.interactivo.tarjetas[0].medio.media_id). |
| 422 | interactivo_invalido |
No cumple los límites, o el medio de una tarjeta no está listo o no es JPEG, PNG o MP4: el campo va en errors[].location. |
| 422 | — | Una encuesta sin pregunta o sin 2 a 12 opciones distintas de hasta 100 caracteres (body.opciones). |
| 422 | cita_ajena |
La cita del interactivo no es de este chat. |
| 404 | — | GET …/interactive/{provider_id} o …/polls/{provider_id} de un mensaje que no es de esta instancia. |
Un interactivo nunca se rechaza por ser de un número no oficial: si WhatsApp no lo acepta, el
comando termina fallido (rechazado_por_whatsapp) y el seguimiento dice rechazado.
Límites de WhatsApp
Sección titulada «Límites de WhatsApp»Lo comprobado con un iPhone (WhatsApp 26.40) y Android (2.26.40), del 19 al 21 de octubre de 2026:
| Variante | Android | iPhone | Respuesta que llega |
|---|---|---|---|
| Botones de respuesta (casos A72, A79) | Se ven y se tocan | Se ven y se tocan | iPhone: buttons_response; Android: template_button_reply |
| Copiar, enlace y llamar (A73, A75, A76, A82) | Se ven y funcionan | Se ven y funcionan | Ninguna (no_observable) |
| PIX (A74, A82) | Llega y no se muestra | Llega y no se muestra | Ninguna |
| Lista (A77, A80) | Se ve y se responde (list_row; probado en un Android Business) |
No se ve | native_flow (en Android) |
| Lista con respaldo de texto (A77, 21/10) | — | Se ve el texto numerado | texto, via: texto |
| Carrusel con imágenes (A78, A81) | Se ve y se toca | Se ve y se toca | template_button_reply, con el índice de la tarjeta |
| Encuesta (A29) | Se vota y se cambia | Se vota y se cambia | poll.vote descifrado |
- Listas en iOS (caso A77, falla F25): iOS no dibuja la lista native flow, la mande una cuenta
normal o una Business. Cerrado como limitación de WhatsApp el 20/10/2026. Usa
respaldo_textoo botones. Ver Listas en iOS. - La fila elegida en Android llegaba como
interactive.unknowncon el flujomenu_options; se corrigió y ahora llegalist_rowcorrelacionada (falla F32, repetida el 21/10/2026). - PIX llega a la sesión del destino pero ningún teléfono lo dibuja fuera de Brasil; otra
instancia del motor lo recibe como
no_soportado(casos A74 y A82). Ver Pagos PIX. - Clics que no se informan: tocar copiar, un enlace o llamar no produjo ningún evento ni ningún
call.*(caso A82, 19/10/2026). - Con la red cortada: un botón tocado mientras el motor no tenía red llegó una sola vez al volver (caso A83, 19/10/2026).
- En un grupo: las respuestas llegan
correlacionadacon elautordel grupo, con los mismos formatos que en un chat directo (caso A84, 19/10/2026). - Votos con demora: un voto desde el iPhone llegó unos 108 s después de tocarlo, a todas las instancias a la vez: la demora es del teléfono o de WhatsApp (caso A29, 17 y 18/10/2026).