API de Chatflow
Conecta tu sistema o tu propio agente a Chatflow: consulta y trabaja los leads del CRM, entérate al momento de lo que pasa con webhooks firmados, y abre conversaciones por WhatsApp con aprobación humana.
Empieza aquí
Tres pasos para tener tu primera integración funcionando.
1. Crea una llave
En la app, entra al negocio que quieres conectar y ve a API y Webhooks. Crea una llave, marca solo los permisos que tu integración necesita y cópiala: se muestra una sola vez.
2. Haz tu primera petición
Todas las rutas viven en https://chatapp.chatflow.mx/api/v1, reciben y devuelven JSON.
curl "https://chatapp.chatflow.mx/api/v1/leads?limit=5" \ -H "Authorization: Bearer TU_LLAVE"
3. Registra un webhook
En API y Webhooks agrega la URL de tu servidor (https) y elige los eventos. Con el botón Probar te llega un evento de prueba en menos de un minuto, para comprobar tu URL y la verificación de la firma antes de que pase algo real. Ver Webhooks.
Llaves y permisos
Cada petición lleva la llave en el encabezado Authorization.
Authorization: Bearer TU_LLAVE
La llave pertenece a un negocio
Una llave solo ve y toca el negocio donde se creó. Si tu cuenta tiene varios negocios, crea una llave en cada uno.
Permisos
El servidor revisa el permiso en cada petición. Sin el permiso responde 403 y dice cuál falta.
leads:read | Leer leads, su historial, sus notas y los eventos. Viene marcado por defecto. |
conversations:read | Leer conversaciones completas. Viene marcado por defecto. |
catalog:read | Leer etapas, equipo y agentes. Viene marcado por defecto. |
leads:write | Crear leads, cambiar su etapa y datos, registrar contactos. |
notes:write | Agregar notas a un lead sin poder tocar nada más. |
whatsapp:propose | Proponer envíos de WhatsApp y consultar su estado. Nada sale sin que alguien apruebe. |
proposals:decide | Aprobar o rechazar propuestas de otras llaves. Nunca las propias. |
agents:chat | Conversar con un agente del negocio. Cada conversación nueva cuenta contra tu plan. |
Revocar
Desde la misma pantalla. La llave deja de funcionar en ese momento.
Leads
Los contactos del CRM y lo que se ha hecho con cada uno.
/leadsleads:readLista los leads del negocio, del más reciente al más viejo.
Parámetros de consulta
stagetexto | Solo los de esa etapa (ver /stages). |
searchtexto | Busca en nombre, correo, teléfono y empresa. |
agent_iduuid | Solo los de ese agente. |
sincefecha ISO | Solo los creados desde esa fecha. |
limitnúmero | Máximo 100.Por defecto: 20 |
offsetnúmero | Para paginar.Por defecto: 0 |
{
"leads": [{
"id": "uuid", "name": "Laura Pérez", "email": "laura@ejemplo.com",
"phone": "+525512345678", "company": "Inmobiliaria Norte",
"source": "whatsapp", "stage": "Contactado", "custom_fields": {},
"created_at": "…", "last_activity": "…", "agent_id": "uuid"
}],
"total": 231, "limit": 20, "offset": 0, "has_more": true
}/leads/{id}leads:readUn lead completo, con su historial y sus notas.
/leadsleads:writeCuerpo
nametextorequerido | Nombre del contacto. |
phonetexto | En formato internacional, +52… |
emailtexto | Correo. |
companytexto | Empresa. |
messagetexto | Lo primero que dijo o pidió. |
agent_iduuid | El agente al que pertenece. |
Responde 201 con el lead creado, en la primera etapa.
/leads/{id}leads:writeActualiza solo los campos que mandes. Cambiar stage emite el evento lead.stage_changed.
Campos que se pueden escribir
name, email, phone, company | Identidad del contacto. |
stage | Una de las etapas del negocio. |
interest, notes | Qué busca y notas generales. |
next_action, next_action_date | El siguiente paso y su fecha. Al vencer se emite lead.followup_due. |
assigned_to | id de una persona del equipo (ver /team). |
deal_value, deal_currency, deal_probability | Valor del negocio. |
custom_fieldsobjeto | Se combina con lo que ya hay; no lo reemplaza. |
/leads/{id}/activitiesleads:read · leads:writeEl historial del lead. Al registrar un contacto (call_made, email_sent, whatsapp_sent, meeting) un lead en la primera etapa pasa solo a la segunda. note_added no mueve la etapa.
{ "type": "call_made", "description": "Le llamé; quiere visitar el sábado" }/leads/{id}/notesleads:read · notes:writeNotas internas. Para agregar: { "content": "…" }.
Conversaciones
Lo que se habló con cada contacto, por cualquier canal.
/conversations?agent_id={id}conversations:readLas conversaciones de un agente, las más recientes primero. agent_id es obligatorio; se pagina con limit (máx. 100) y offset.
/conversations/{id}conversations:readLa conversación con todos sus mensajes (role: user o assistant).
Etapas, equipo y agentes
Lo que necesitas para escribir valores válidos.
/stagescatalog:readLas etapas del embudo del negocio, en orden. Usa estos nombres en stage.
/teamcatalog:readLas personas del equipo. Su id es el que va en assigned_to.
/agentscatalog:readLos agentes (chatbots) del negocio.
/chatagents:chatHabla con uno de tus agentes desde tu sistema, con su conocimiento y sus instrucciones. Para seguir la misma conversación manda el conversation_id que te devolvió la primera respuesta. Con stream: true la respuesta llega en partes como texto plano, y el id viene en el encabezado X-Conversation-Id.
{ "agent_id": "uuid", "message": "¿Tienen departamentos en Juriquilla?", "conversation_id": null }{ "conversation_id": "uuid", "message": { "id": "uuid", "role": "assistant", "content": "Sí, tenemos…", "created_at": "…" } }Si una persona tomó el control de esa conversación, el agente no contesta: la respuesta trae human_control: true y message: null.
Webhooks y eventos
Chatflow le avisa a tu servidor cuando pasa algo, en vez de que preguntes cada minuto.
Cómo llega un evento
Un POST a tu URL con este cuerpo. data cambia según el evento; lo demás es igual para todos.
{
"id": "uuid del evento",
"type": "lead.stage_changed",
"created_at": "2026-10-01T17:04:12.345Z",
"lead_id": "uuid",
"conversation_id": null,
"agent_id": "uuid",
"data": { "stage": "Calificado", "changed_by": "human" }
}Encabezados
X-ChatFlow-Event | El tipo de evento. |
X-ChatFlow-Timestamp | Segundos desde 1970 (Unix) en que se firmó. |
X-ChatFlow-Signature | HMAC-SHA256 en hexadecimal de "timestamp.cuerpo" con el secreto del webhook. |
Verifica la firma
Sin verificarla, cualquiera que conozca tu URL puede inventarte eventos. Calcula el HMAC-SHA256 de `${timestamp}.${cuerpo}` con tu secreto y compáralo con la firma. Usa el cuerpo tal como llegó, antes de convertirlo a JSON: cualquier espacio de diferencia cambia la firma.
import crypto from 'node:crypto'
// En Express usa express.raw({ type: 'application/json' }) en esta ruta:
// la firma se calcula sobre el cuerpo exacto, antes de convertirlo a JSON.
app.post('/chatflow', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.header('X-ChatFlow-Timestamp')
const signature = req.header('X-ChatFlow-Signature')
const body = req.body.toString('utf8')
const expected = crypto
.createHmac('sha256', process.env.CHATFLOW_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest('hex')
const valid =
signature?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
// Rechaza también lo viejo: evita que alguien reenvíe un evento capturado.
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300
if (!valid || !fresh) return res.status(401).end()
const event = JSON.parse(body)
res.status(200).end() // responde rápido...
handle(event) // ...y procesa después (descarta ids ya vistos)
})import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CHATFLOW_WEBHOOK_SECRET"].encode()
@app.post("/chatflow")
def chatflow():
timestamp = request.headers.get("X-ChatFlow-Timestamp", "")
signature = request.headers.get("X-ChatFlow-Signature", "")
body = request.get_data(as_text=True)
expected = hmac.new(SECRET, f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
if abs(time.time() - int(timestamp)) > 300:
abort(401)
event = request.get_json()
# ... descarta event["id"] si ya lo procesaste
return "", 200¿Perdiste el secreto? En la pantalla del webhook, Generar secreto nuevo. El anterior deja de valer en ese momento.
Reintentos y garantía
Responde con un código 2xx en menos de 10 segundos; si tu proceso tarda, responde primero y procesa después. Si no responde o responde con error, se reintenta a 1, 5, 30 y 120 minutos; después del quinto intento se marca como fallido. En la pantalla del webhook ves cada entrega con su código de respuesta.
id que ya procesaste y descarta los repetidos, o tu integración hará la misma acción dos veces./events?since={fecha ISO}leads:readLo que pasó mientras tu servidor no estaba: llámalo al arrancar, no cada minuto. since es exclusivo; manda de vuelta el cursor de la respuesta anterior y no se repite el último. Filtra con type; limit por defecto 100, máximo 500. Cada evento trae los mismos campos que el webhook (data incluido).
{ "events": [ … ], "cursor": "2026-10-01T17:04:12.345Z", "has_more": false }Todos los eventos
lead.created
Entró un lead por cualquier canal, o nació de un envío de WhatsApp saliente.
{
"name": "Laura Pérez",
"source": "whatsapp",
"interest": "Departamento en renta"
}lead.stage_changed
Alguien movió el lead de etapa, desde la app (human) o por API (api).
{
"stage": "Calificado",
"changed_by": "api",
"api_key_id": "uuid"
}lead.stale
Un lead abierto lleva días sin actividad. Se avisa una sola vez por lead.
{
"stage": "Contactado",
"last_activity": "2026-09-20T17:04:00Z",
"days_quiet": 10
}lead.followup_due
Llegó la fecha del próximo paso agendado en el lead.
{
"next_action": "Llamar para confirmar visita",
"due_date": "2026-10-01",
"stage": "Propuesta"
}lead.followup_updated
Cambió, se completó o se canceló el próximo paso de un lead.
{
"operation": "set",
"next_action": "Demo de producto",
"due_date": "2026-10-08T17:00:00Z",
"changed_by": "sales_copilot",
"command_id": "uuid"
}conversation.ended
Una conversación lleva 30 minutos sin mensajes. Dice si se capturó un lead.
{
"captured": true,
"reason": null
}alert.replied
Un vendedor respondió por WhatsApp a la alerta de un lead y eso movió la etapa.
{
"stage": "Contactado",
"reply": "ya le llamé"
}message.received
Un contacto escribió por WhatsApp. Trae el texto y, si contesta a un envío tuyo, cuál. Abre la ventana de 24 horas para responder con texto libre.
{
"channel": "whatsapp",
"from": "+525512345678",
"profile_name": "Laura",
"text": "Sí, me interesa. ¿Tienen algo en Juriquilla?",
"media_type": null,
"provider_message_id": "wamid.HBg…",
"in_reply_to": "uuid del envío",
"owned_by_api_key": true,
"window_expires_at": "2026-10-02T18:30:00Z"
}message.status
Un envío de WhatsApp saliente fue aceptado, entregado, leído o falló.
{
"message_id": "uuid",
"status": "delivered"
}contact.opted_out
Un teléfono escribió "baja", "stop" o similar. Ya está en la lista de exclusión del negocio.
{
"channel": "whatsapp",
"phone": "+525512345678",
"text": "baja"
}proposal.created
Un agente propuso un envío y espera aprobación.
{
"proposal_id": "uuid",
"kind": "whatsapp_message",
"message_id": "uuid",
"mode": "template",
"to": "+525512345678",
"contact": {
"name": "Laura Pérez"
},
"template": {
"name": "apertura_v1",
"language": "es_MX"
},
"rendered_body": "Hola Laura, …",
"reason": "Abrió sucursal en Querétaro"
}proposal.decided
Se aprobó, se rechazó (con el motivo en note) o dejó de aplicar (superseded) porque el contacto contestó o el lead cambió.
{
"proposal_id": "uuid",
"message_id": "uuid",
"status": "rejected",
"note": "No es el perfil"
}WhatsApp saliente
Abre conversaciones por WhatsApp desde tu sistema o tu agente. Nada sale sin que una persona lo apruebe.
Cómo funciona
- Tu agente propone un mensaje con
POST /whatsapp/messages. - Alguien del negocio lo aprueba en la pantalla Propuestas, o con otra llave que tenga
proposals:decide. - Chatflow lo envía cuando el número tiene cupo, en horario hábil.
- Si el contacto no era lead, queda registrado con fuente
whatsapp_outbound. - Cuando contesta, te llega
message.receivedcon lo que dijo, y la respuesta queda en la misma conversación.
Antes de empezar
- Conecta tu número de WhatsApp Business (Meta) en Integraciones, desde el negocio que va a enviar.
- Crea y aprueba tus plantillas en tu cuenta de Meta. El primer contacto siempre va con plantilla.
- Crea una llave con
whatsapp:propose.
Dos modos
template | Primer contacto, o cuando el contacto no ha escrito en 24 horas. Plantilla aprobada por Meta más los valores de sus variables. |
text | Texto libre, solo si el contacto escribió en las últimas 24 horas. Lo calcula Chatflow con los mensajes que recibió; si la ventana está cerrada responde 409 window_closed. |
/whatsapp/templateswhatsapp:proposeTus números y sus plantillas aprobadas, con las variables que pide cada una. Llena variables con esas mismas llaves.
{
"numbers": [{
"integration_id": "uuid",
"phone_number": "+52…",
"templates": [{
"name": "apertura_v1", "language": "es_MX", "category": "MARKETING",
"variables": ["body:1", "body:2"],
"body": "Hola {{1}}, vi que {{2}}. ¿Platicamos?"
}]
}]
}/whatsapp/messageswhatsapp:proposePropone un envío. El encabezado Idempotency-Key es obligatorio: si repites la petición con la misma llave y el mismo cuerpo (por ejemplo tras un timeout) recibes el envío original, no uno nuevo.
curl -X POST "https://chatapp.chatflow.mx/api/v1/whatsapp/messages" \
-H "Authorization: Bearer TU_LLAVE" \
-H "Idempotency-Key: apertura-laura-2026-10-01" \
-H "Content-Type: application/json" \
-d '{
"to": "+525512345678",
"mode": "template",
"template": {
"name": "apertura_v1",
"language": "es_MX",
"variables": { "body:1": "Laura", "body:2": "abriste sucursal en Querétaro" }
},
"reason": "Abrió sucursal; encaja con el perfil",
"contact": { "name": "Laura Pérez", "company": "Inmobiliaria Norte" }
}'Cuerpo
lead_iduuid | El lead al que va. O usa to. |
totexto | Teléfono en formato internacional, si todavía no es lead. |
modetemplate | textrequerido | Ver arriba. |
templateobjeto | name, language y variables. Requerido en modo template. |
texttexto | Requerido en modo text. Máximo 4096 caracteres. |
reasontexto | Por qué propones este envío. Lo ve quien aprueba. |
contactobjeto | name, company, email, interest, notes. Con esto se registra el lead si no existía. |
integration_iduuid | Solo si el negocio tiene más de un número. |
{
"message": {
"id": "uuid", "status": "pending_approval", "mode": "template",
"to": "+525512345678", "lead_id": null, "proposal_id": "uuid",
"rendered_body": "Hola Laura, vi que abriste sucursal en Querétaro. ¿Platicamos?"
},
"replayed": false
}/whatsapp/messages/{id}whatsapp:proposeEn qué va un envío. También GET /whatsapp/messages?status=&lead_id= para listar. Cada cambio llega además como evento message.status o proposal.decided.
Estados
pending_approval | Espera que alguien decida. |
queued | Aprobado; espera su turno según el ritmo del número. |
sending → accepted | Salió y Meta lo aceptó. |
delivered → read | Llegó al teléfono y se leyó. |
rejected | Alguien lo rechazó; el motivo viene en decision_note. |
superseded | Dejó de aplicar antes de aprobarse: el contacto contestó, pidió la baja o el lead cambió de etapa. |
failed | No salió; el motivo viene en error. |
El ritmo del número
Un número nuevo que manda mucho de golpe termina bloqueado por Meta. Por eso cada número tiene un tope diario que crece mientras se calienta: 5, 10 y 20 mensajes al día las primeras tres semanas, en horario hábil (9 a 18, lunes a viernes, hora de la Ciudad de México) y con huecos irregulares entre envíos. El tope cuenta todo lo que sale del número, campañas incluidas. Un envío aprobado espera en queued hasta su turno.
Cuando el contacto contesta
Te llega message.received con el texto y in_reply_to (el envío al que contesta). La respuesta queda en la misma conversación aunque llegue días después, y el chatbot de Chatflow no contesta en conversaciones que abrió tu agente. Desde ese momento tienes 24 horas para responder con mode: "text". Si escribe "baja" o "stop", te llega contact.opted_out y ese teléfono ya no recibe envíos.
Propuestas
Lo que un agente sugiere y una persona decide.
/proposals?status=pendingwhatsapp:propose o proposals:decideLas propuestas del negocio con el envío que proponen. status: pending, approved, rejected o superseded.
/proposals/{id}/approveproposals:decideAprueba y pone el envío en fila. Puedes ajustar al aprobar: { "note": "…", "variables": { … } } o { "text": "…" }; se validan igual que al proponer. Si lo que justificaba la propuesta cambió, responde 409 superseded y no envía.
/proposals/{id}/rejectproposals:decideRechaza con { "note": "motivo" }. El motivo le llega al agente en proposal.decided.
Errores y límites
Formato
Los errores traen un código estable para tu programa y un mensaje para una persona.
{ "error": "missing_scope", "required": "leads:write", "message": "Esta llave no tiene el permiso \"leads:write\"." }Códigos
400 validation_error | Falta un campo o tiene formato inválido. |
400 template_invalid | La plantilla no está aprobada o las variables no coinciden (trae missing, unexpected y expected). |
400 workspace_required | La llave es anterior a los negocios; crea una nueva desde el negocio. |
401 unauthorized | Falta la llave, es inválida, expiró o fue revocada. |
403 missing_scope | La llave no tiene el permiso; dice cuál. |
404 not_found | No existe en este negocio. |
409 window_closed · suppressed · already_pending · superseded | El envío no puede seguir; el mensaje explica por qué. |
422 idempotency_conflict | Esa Idempotency-Key ya se usó con otro contenido. |
429 rate_limited | Pasaste el límite; espera un minuto. |
Límite de peticiones
100 peticiones por minuto por llave. Los envíos de WhatsApp, además, salen al ritmo de su número.
¿Algo no funciona como dice aquí? Escríbenos a support@chatflow.mx.
