Guías
WhatsApp Flows
Un Flow es un formulario que se abre dentro del chat de WhatsApp: agendar una hora, registrarse, responder una encuesta o armar un pedido. El cliente lo completa sin salir de WhatsApp y la respuesta te llega por webhook. Solo existe en WhatsApp Cloud.
Cómo funciona#
- Los Flows viven en la cuenta de WhatsApp (WABA) de tu número, en Meta. heysteff guarda una copia para listarlos sin ir a Meta y para su configuración propia (
save_to_contact). - Cada Flow pasa por
DRAFT(borrador: se edita y se prueba),PUBLISHED(se manda a cualquier cliente y ya no cambia) yDEPRECATED(ya no se manda ni se abre). Meta puede dejarlo enBLOCKEDoTHROTTLEDsi su salud baja. - Las pantallas se definen en el Flow JSON, el formato de Meta (referencia del Flow JSON). heysteff lo sube tal cual y te devuelve los errores que encuentre Meta.
- Necesitas un número de WhatsApp Cloud conectado (no sirve WhatsApp por QR) y una key
sk_live_conflows:readyflows:write. Una key de prueba responde 403test_key_not_allowed: el número compartido del sandbox no tiene Flows tuyos.
Crear y publicar un Flow#
POST /v1/whatsapp-flowscrea un borrador conname,categories(SIGN_UP,SIGN_IN,APPOINTMENT_BOOKING,LEAD_GENERATION,CONTACT_US,CUSTOMER_SUPPORT,SURVEYuOTHER) y, si quieres, elflow_json(objeto o texto, hasta 1 MB). Sinchannel_connection_idse crea en la cuenta del número de WhatsApp Cloud conectado más reciente.- Revisa
validation_errorsen la respuesta: vacío quiere decir que el Flow JSON está listo. Si hay errores, corrígelos y sube el JSON de nuevo conPUT /v1/whatsapp-flows/{id}/json. - Pruébalo:
GET /v1/whatsapp-flows/{id}/previewda un link que abre el formulario en el navegador, y también puedes mandártelo conmode: "draft". POST /v1/whatsapp-flows/{id}/publishlo publica. Desde ahí ya no cambia: para otra versión, crea otro Flow.
curl https://app.heysteff.com/api/v1/whatsapp-flows \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Agendar visita",
"categories": ["APPOINTMENT_BOOKING"],
"save_to_contact": true,
"flow_json": {
"version": "6.3",
"screens": [
{
"id": "AGENDAR",
"title": "Agenda tu visita",
"terminal": true,
"layout": {
"type": "SingleColumnLayout",
"children": [
{ "type": "TextInput", "name": "nombre", "label": "Nombre", "required": true },
{ "type": "DatePicker", "name": "fecha", "label": "Fecha", "required": true },
{
"type": "Footer",
"label": "Enviar",
"on-click-action": {
"name": "complete",
"payload": { "nombre": "${form.nombre}", "fecha": "${form.fecha}" }
}
}
]
}
}
]
}
}'{
"data": {
"id": "0b6f4c1e-2d3a-4b5c-8d9e-1f2a3b4c5d6e",
"flow_id": "1234567890123456",
"channel_connection_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"name": "Agendar visita",
"status": "DRAFT",
"categories": ["APPOINTMENT_BOOKING"],
"validation_errors": [],
"save_to_contact": true,
"responses_count": 0
}
}Guarda los dos ids: id es el de heysteff (el de las rutas /v1/whatsapp-flows/{id}) y flow_id es el de Meta, el que va en el mensaje y en el botón de una plantilla.
import { Heysteff } from '@heysteff/sdk';
const heysteff = new Heysteff({ apiKey: process.env.HEYSTEFF_API_KEY });
const flow = await heysteff.whatsappFlows.create({
name: 'Agendar visita',
categories: ['APPOINTMENT_BOOKING'],
flow_json: flowJson,
});
if (flow.validation_errors.length === 0) await heysteff.whatsappFlows.publish(flow.id);
const { preview_url } = await heysteff.whatsappFlows.preview(flow.id);| Endpoint | Scope | Qué hace |
|---|---|---|
GET /v1/whatsapp-flows | flows:read | Tus Flows, con channel_connection_id y status como filtros. |
POST /v1/whatsapp-flows | flows:write | Crea un borrador (con publish: true lo publica al crearlo si no tiene errores). |
POST /v1/whatsapp-flows/sync | flows:write | Trae los Flows que creaste en WhatsApp Manager y borra de heysteff los que ya no existen. |
GET /v1/whatsapp-flows/{id} | flows:read | Un Flow con su estado y sus errores. |
PATCH /v1/whatsapp-flows/{id} | flows:write | Nombre, categorías o save_to_contact. |
GET /v1/whatsapp-flows/{id}/json | flows:read | El Flow JSON (el subido desde heysteff o, si no hay, el que tiene Meta). |
PUT /v1/whatsapp-flows/{id}/json | flows:write | Sube o reemplaza el Flow JSON de un borrador. |
POST /v1/whatsapp-flows/{id}/publish | flows:write | Publica un borrador. |
POST /v1/whatsapp-flows/{id}/deprecate | flows:write | Retira un Flow publicado. |
DELETE /v1/whatsapp-flows/{id} | flows:write | Borra un borrador (uno publicado se depreca, no se borra). |
GET /v1/whatsapp-flows/{id}/preview | flows:read | Link de vista previa (vence a los 30 días; ?invalidate=true pide otro). |
GET /v1/whatsapp-flows/responses | flows:read | Las respuestas de los clientes. |
Desde la consola: en la consola de developers, la sección Flows de WhatsApp lista tus Flows, los crea, sube el JSON, los publica o depreca, los sincroniza con Meta y muestra las respuestas. Desde tu IA: las tools MCP list_whatsapp_flows, get_whatsapp_flow, create_whatsapp_flow, update_whatsapp_flow, upload_whatsapp_flow_json, publish_whatsapp_flow, deprecate_whatsapp_flow, delete_whatsapp_flow, get_whatsapp_flow_preview, sync_whatsapp_flows y list_whatsapp_flow_responses.
Mandar un Flow#
Con la conversación abierta (dentro de las 24 horas desde el último mensaje del cliente), manda content_type: "flow" por POST /v1/messages. Sale como un mensaje con un botón que abre el formulario.
curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content_type": "flow",
"flow": {
"flow_id": "1234567890123456",
"cta": "Agendar",
"body": "Elige el día de tu visita y te confirmamos enseguida.",
"flow_token": "pedido-1042"
}
}'| Campo | Qué es |
|---|---|
flow_id | Obligatorio. El id del Flow en Meta (flow_id de GET /v1/whatsapp-flows). |
cta | Obligatorio. Texto del botón, hasta 30 caracteres. |
body | Obligatorio. Hasta 1.024 caracteres. |
header, footer | Opcionales, hasta 60 caracteres. |
flow_token | Hasta 512 caracteres. Vuelve con la respuesta: úsalo para saber a qué pedido o caso corresponde. Si no lo mandas, heysteff genera uno hs_…. |
mode | published (por defecto) o draft para probar un borrador. |
action | navigate (por defecto) abre una pantalla; data_exchange le pide la primera al endpoint de datos de tu Flow. |
screen, data | Solo con navigate: la pantalla inicial (por defecto, la primera) y sus datos iniciales. |
Fuera de las 24 horas responde 409 wa_window_closed: usa una plantilla con botón FLOW. Se crea con POST /v1/templates y un botón {"type": "FLOW", "text": "Agendar", "flow_id": "1234567890123456", "flow_action": "navigate", "navigate_screen": "AGENDAR"} (el Flow tiene que estar publicado), y se manda así:
{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content_type": "template",
"template": {
"name": "agendar_visita",
"language": "es",
"components": [
{
"type": "button",
"sub_type": "flow",
"index": "0",
"parameters": [
{ "type": "action", "action": { "flow_token": "pedido-1042", "flow_action_data": { "sucursal": "centro" } } }
]
}
]
}
}Si Meta rechaza el Flow al enviarlo (por ejemplo, porque está deprecado), el mensaje queda failed y llega el webhook message.failed. No se reemplaza por texto: un formulario convertido en texto no le sirve al cliente.
Endpoint de datos (data_exchange): heysteff todavía no aloja el endpoint cifrado que usa un Flow para pedir datos en vivo entre pantallas. Si tu Flow lo necesita, configúralo en Meta con tu propio servidor; heysteff igual manda el Flow con action: "data_exchange" y te entrega la respuesta final.
Recibir las respuestas#
Cuando el cliente envía el formulario llega el webhook message:received con flow_response: el flow_token con que mandaste el Flow y los campos en data, con las claves de tu Flow JSON. El content del mensaje es la misma respuesta en texto (una línea campo: valor por campo), que es lo que ve tu equipo en la bandeja.
{
"event": "message:received",
"data": {
"conversation_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"message_id": "4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b",
"channel": "whatsapp_cloud",
"content": "nombre: Ana\nfecha: 2026-11-20",
"content_type": "interactive",
"interactive_reply": null,
"flow_response": {
"flow_token": "pedido-1042",
"data": { "nombre": "Ana", "fecha": "2026-11-20" }
}
}
}En cualquier otro mensaje, flow_response es null. Lo mismo trae GET /v1/messages/{id}. Para leerlas después, GET /v1/whatsapp-flows/responses las lista de la más nueva a la más vieja (flow_id filtra por el id heysteff del Flow; para la página siguiente, manda before con el next_before de la respuesta).
Guardar las respuestas en el contacto#
Con save_to_contact: true (al crear el Flow o con PATCH /v1/whatsapp-flows/{id}), cada campo simple de la respuesta (texto, número, sí/no o una lista de textos) se guarda en el campo del contacto con la misma clave. Las claves tienen que ir en minúsculas, con números y guiones bajos, empezando por una letra (nombre, fecha_visita). Si el campo existe en Contactos, el valor se valida con su tipo; si no existe, se guarda tal cual. La respuesta trae saved_to_contact en GET /v1/whatsapp-flows/responses.
Errores#
| Código | Qué pasó |
|---|---|
whatsapp_cloud_only (422) | Mandaste un Flow por otro canal, o el remitente no es un número de WhatsApp Cloud. |
test_key_not_allowed (403) | Usaste una key sk_test_: los Flows necesitan una sk_live_. |
no_whatsapp_cloud_channel (422) | El espacio no tiene un número de WhatsApp Cloud conectado. |
flow_json_invalid (422) | El Flow JSON no es un objeto JSON, pesa más de 1 MB o Meta lo rechazó. El detalle (línea, columna y ruta de cada error) viene en details.validation_errors. |
flow_not_draft (409) | El Flow ya está publicado: no cambia su JSON, no se vuelve a publicar ni se borra (deprécalo). |
flow_not_published (409) | Solo se depreca un Flow publicado; un borrador se borra. |
wa_window_closed (409) | Pasaron más de 24 horas desde el último mensaje del cliente: manda una plantilla con botón FLOW. |
meta_flow_error (424) | Meta rechazó la operación o no respondió. Revisa message; el token del número necesita el permiso whatsapp_business_management. |