developers
EN Crear cuenta

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#

Crear y publicar un Flow#

  1. POST /v1/whatsapp-flows crea un borrador con name, categories (SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY u OTHER) y, si quieres, el flow_json (objeto o texto, hasta 1 MB). Sin channel_connection_id se crea en la cuenta del número de WhatsApp Cloud conectado más reciente.
  2. Revisa validation_errors en la respuesta: vacío quiere decir que el Flow JSON está listo. Si hay errores, corrígelos y sube el JSON de nuevo con PUT /v1/whatsapp-flows/{id}/json.
  3. Pruébalo: GET /v1/whatsapp-flows/{id}/preview da un link que abre el formulario en el navegador, y también puedes mandártelo con mode: "draft".
  4. POST /v1/whatsapp-flows/{id}/publish lo publica. Desde ahí ya no cambia: para otra versión, crea otro Flow.
curl
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}" }
                }
              }
            ]
          }
        }
      ]
    }
  }'
JSON
{
  "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.

TypeScript
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);
EndpointScopeQué hace
GET /v1/whatsapp-flowsflows:readTus Flows, con channel_connection_id y status como filtros.
POST /v1/whatsapp-flowsflows:writeCrea un borrador (con publish: true lo publica al crearlo si no tiene errores).
POST /v1/whatsapp-flows/syncflows:writeTrae los Flows que creaste en WhatsApp Manager y borra de heysteff los que ya no existen.
GET /v1/whatsapp-flows/{id}flows:readUn Flow con su estado y sus errores.
PATCH /v1/whatsapp-flows/{id}flows:writeNombre, categorías o save_to_contact.
GET /v1/whatsapp-flows/{id}/jsonflows:readEl Flow JSON (el subido desde heysteff o, si no hay, el que tiene Meta).
PUT /v1/whatsapp-flows/{id}/jsonflows:writeSube o reemplaza el Flow JSON de un borrador.
POST /v1/whatsapp-flows/{id}/publishflows:writePublica un borrador.
POST /v1/whatsapp-flows/{id}/deprecateflows:writeRetira un Flow publicado.
DELETE /v1/whatsapp-flows/{id}flows:writeBorra un borrador (uno publicado se depreca, no se borra).
GET /v1/whatsapp-flows/{id}/previewflows:readLink de vista previa (vence a los 30 días; ?invalidate=true pide otro).
GET /v1/whatsapp-flows/responsesflows:readLas 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
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"
    }
  }'
CampoQué es
flow_idObligatorio. El id del Flow en Meta (flow_id de GET /v1/whatsapp-flows).
ctaObligatorio. Texto del botón, hasta 30 caracteres.
bodyObligatorio. Hasta 1.024 caracteres.
header, footerOpcionales, hasta 60 caracteres.
flow_tokenHasta 512 caracteres. Vuelve con la respuesta: úsalo para saber a qué pedido o caso corresponde. Si no lo mandas, heysteff genera uno hs_….
modepublished (por defecto) o draft para probar un borrador.
actionnavigate (por defecto) abre una pantalla; data_exchange le pide la primera al endpoint de datos de tu Flow.
screen, dataSolo 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í:

json
{
  "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.

JSON
{
  "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ódigoQué 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.