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.

La llave da acceso a los datos de tu negocio. Guárdala como secreto en tu servidor; nunca en código que corra en el navegador ni en el texto de un prompt.

2. Haz tu primera petición

Todas las rutas viven en https://chatapp.chatflow.mx/api/v1, reciben y devuelven JSON.

bash
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.

Encabezado
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.

GET/leadsleads:read

Lista los leads del negocio, del más reciente al más viejo.

Parámetros de consulta

stage
texto
Solo los de esa etapa (ver /stages).
search
texto
Busca en nombre, correo, teléfono y empresa.
agent_id
uuid
Solo los de ese agente.
since
fecha ISO
Solo los creados desde esa fecha.
limit
número
Máximo 100.Por defecto: 20
offset
número
Para paginar.Por defecto: 0
Respuesta
{
  "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
}
GET/leads/{id}leads:read

Un lead completo, con su historial y sus notas.

POST/leadsleads:write

Cuerpo

name
textorequerido
Nombre del contacto.
phone
texto
En formato internacional, +52…
email
texto
Correo.
company
texto
Empresa.
message
texto
Lo primero que dijo o pidió.
agent_id
uuid
El agente al que pertenece.

Responde 201 con el lead creado, en la primera etapa.

PATCH/leads/{id}leads:write

Actualiza 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_fields
objeto
Se combina con lo que ya hay; no lo reemplaza.
GET · POST/leads/{id}/activitiesleads:read · leads:write

El 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.

POST
{ "type": "call_made", "description": "Le llamé; quiere visitar el sábado" }
GET · POST/leads/{id}/notesleads:read · notes:write

Notas internas. Para agregar: { "content": "…" }.

Conversaciones

Lo que se habló con cada contacto, por cualquier canal.

GET/conversations?agent_id={id}conversations:read

Las conversaciones de un agente, las más recientes primero. agent_id es obligatorio; se pagina con limit (máx. 100) y offset.

GET/conversations/{id}conversations:read

La conversación con todos sus mensajes (role: user o assistant).

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.

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

Node.js
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)
})
Python
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.

La garantía es al menos una vez: un evento puede llegarte dos veces (por ejemplo, si tu servidor lo procesó pero la respuesta se perdió). Guarda los id que ya procesaste y descarta los repetidos, o tu integración hará la misma acción dos veces.
GET/events?since={fecha ISO}leads:read

Lo 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).

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

data
{
  "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).

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

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

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

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

data
{
  "captured": true,
  "reason": null
}

alert.replied

Un vendedor respondió por WhatsApp a la alerta de un lead y eso movió la etapa.

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

data
{
  "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ó.

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

data
{
  "channel": "whatsapp",
  "phone": "+525512345678",
  "text": "baja"
}

proposal.created

Un agente propuso un envío y espera aprobación.

data
{
  "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ó.

data
{
  "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

  1. Tu agente propone un mensaje con POST /whatsapp/messages.
  2. Alguien del negocio lo aprueba en la pantalla Propuestas, o con otra llave que tenga proposals:decide.
  3. Chatflow lo envía cuando el número tiene cupo, en horario hábil.
  4. Si el contacto no era lead, queda registrado con fuente whatsapp_outbound.
  5. Cuando contesta, te llega message.received con 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.
GET/whatsapp/templateswhatsapp:propose

Tus números y sus plantillas aprobadas, con las variables que pide cada una. Llena variables con esas mismas llaves.

Respuesta
{
  "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?"
    }]
  }]
}
POST/whatsapp/messageswhatsapp:propose

Propone 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.

bash
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_id
uuid
El lead al que va. O usa to.
to
texto
Teléfono en formato internacional, si todavía no es lead.
mode
template | textrequerido
Ver arriba.
template
objeto
name, language y variables. Requerido en modo template.
text
texto
Requerido en modo text. Máximo 4096 caracteres.
reason
texto
Por qué propones este envío. Lo ve quien aprueba.
contact
objeto
name, company, email, interest, notes. Con esto se registra el lead si no existía.
integration_id
uuid
Solo si el negocio tiene más de un número.
Respuesta 202
{
  "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
}
Las variables no aceptan saltos de línea, tabuladores ni más de cuatro espacios seguidos (Meta las rechaza). Chatflow lo valida al proponer para que el error te llegue a ti, no a quien aprobó.
GET/whatsapp/messages/{id}whatsapp:propose

En 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.

GET/proposals?status=pendingwhatsapp:propose o proposals:decide

Las propuestas del negocio con el envío que proponen. status: pending, approved, rejected o superseded.

POST/proposals/{id}/approveproposals:decide

Aprueba 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.

POST/proposals/{id}/rejectproposals:decide

Rechaza con { "note": "motivo" }. El motivo le llega al agente en proposal.decided.

Una llave nunca puede decidir sobre lo que ella misma propuso (403 self_approval), aunque tenga los dos permisos.

Errores y límites

Formato

Los errores traen un código estable para tu programa y un mensaje para una persona.

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