API REST, versión 1
Documentación de BotwAIp
Integra tus sistemas con la plataforma para enviar mensajes, crear pedidos y recibir eventos por webhook.
Introducción
La API de BotwAIp permite que tu CRM, ERP, e-commerce u otra aplicación envíe mensajes de WhatsApp a tus contactos y reciba notificaciones en tiempo real cuando ocurra algo relevante (cliente respondió, agente tomó la conversación, etc.).
Es una API REST con JSON. Todos los endpoints viven bajo https://panel.botwaip.com/api/external y se autentican con un header X-Bot-Api-Key.
Qué puedes hacer
| Puedes | Con qué |
|---|---|
| Escribirle a un contacto por WhatsApp desde tu sistema, con texto o con una plantilla aprobada. | POST /messages/send |
| Escribirle también a quien no enseña su número, con el identificador que te dio el webhook. | Número o usuario |
| Dejar que la API resuelva sola la ventana de 24 h: texto si está abierta, plantilla si no. | Plantillas |
| Decidir quién atiende cuando el cliente responda: la IA, un agente, o nadie. | on_reply |
| Marcar cada envío con tu propio identificador para cruzarlo después con tu CRM o tu ERP. | external_ref |
| Recibir en tu servidor, en el momento, lo que pasa en la conversación: mensajes, cola, cierre. | Webhooks |
| Crear, listar, editar y borrar tus webhooks sin entrar al panel, y ver por qué fallan. | /webhooks |
| Comprobar desde tu código que la key y el número de WhatsApp siguen activos. | GET /me |
| Poner el chat del bot en tu web, o construir tu propia interfaz de chat contra él. | Web Chat |
Lo que no hace esta API: no crea bots, ni asistentes, ni flujos, ni contactos sueltos — eso se configura en el panel. Aquí se envía y se escucha.
El Web Chat va aparte y con otras reglas: es el canal del navegador, se identifica con una clave pública y no usa la API Key de arriba.
¿Listo para empezar?
- Inicia sesión en el panel y entra a Configuración → Integraciones de API.
- Crea una nueva API Key con un nombre que identifique tu sistema.
- Copia la key (solo se muestra una vez) y guárdala en tu app.
- Usa los endpoints de abajo desde tu código.
Autenticación
Cada petición requiere el header X-Bot-Api-Key con la API Key generada desde el panel. Las keys tienen el formato bwk_ seguido de 32 caracteres hexadecimales aleatorios.
X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Si tu cliente HTTP ya usa X-Api-Key para otras integraciones, también lo aceptamos como alias. Manda uno de los dos, no los dos.
Múltiples keys por bot
Puedes crear una key por cada sistema que integres (CRM, ERP, app móvil, etc.). Cada key tiene su propio set de webhooks, sus métricas de uso, y se puede revocar de forma independiente sin afectar a las demás.
Seguridad
- La key plain solo se muestra una vez al crearla. Si la pierdes, genera una nueva y revoca la anterior.
- La BD guarda solo el hash SHA-256 — nadie puede recuperar tu key, ni siquiera el soporte.
- Si una key se compromete, revócala desde el panel: deja de funcionar inmediatamente.
- No incluyas la key en código fuente público ni en URLs (siempre va en header).
Canal de envío (WhatsApp)
Cada API Key está obligatoriamente asignada a un canal WhatsApp específico. El endpoint /messages/send envía siempre desde ese canal — no hay modo automático ni fallback, lo cual hace el comportamiento predecible y evita que un cambio en la configuración del bot afecte silenciosamente a tu integración.
Asignar el canal
El canal se elige al crear la API Key desde el panel. Si el bot tiene varios números de WhatsApp conectados (ej. Ventas, Soporte), selecciona el que corresponde a esta integración. Si después necesitas cambiarlo, puedes editarlo desde la lista de API Keys sin tener que generar una nueva — el cambio toma efecto inmediato.
Verificar el canal asignado
El endpoint GET /me devuelve el canal asignado a tu API Key y su estado:
{
"bot_id": "uuid",
"bot_name": "Mi Tienda",
"api_key": { "id": "uuid", "name": "CRM HubSpot" },
"whatsapp_configured": true,
"channel_status": "active", // "active" | "inactive" | "deleted"
"phone_number_id": "1234567890",
"display_phone_number": "+57 300 123 4567",
"verified_name": "Mi Tienda S.A.S.",
"active": true
}
channel_status tiene 3 valores:
"active"— el canal está OK, listo para enviar."inactive"— el canal existe pero fue desactivado./messages/sendva a fallar."deleted"— el canal fue eliminado. La API Key quedó huérfana y el admin debe asignarle un canal nuevo.
Errores relacionados al canal
| Código de error | Cuándo ocurre |
|---|---|
| channel_not_bound (422) | La API Key no tiene un canal asignado, generalmente porque el canal original fue eliminado. El admin debe asignarle un canal activo desde el panel o generar una nueva API Key. |
| bound_channel_inactive (422) | El canal asignado a la key fue desactivado. El admin debe reactivar el canal o asignarle otro a la key. |
| canal_sin_token (422) | El canal existe pero su configuración no tiene token de Meta. Reconfigurarlo desde el panel. |
Rate limiting
Cada API key tiene un límite de 60 peticiones por minuto (compartido entre todos los endpoints /api/external/*). Si lo superas, recibes un 429 Too Many Requests.
Para envíos masivos de notificaciones (más de 60/min), espacia las peticiones desde tu sistema o pídenos un aumento de cuota.
Errores
Todos los errores devuelven un JSON con un campo error (código corto) y message (descripción human-readable). Los códigos HTTP siguen los estándares REST:
| Código | Significado |
|---|---|
| 200 | Éxito. La respuesta incluye el resultado. |
| 201 | Recurso creado (mensaje enviado, webhook creado, etc.). |
| 400 | Datos inválidos en el body. Revisa details. |
| 401 | API Key faltante, inválida o revocada. |
| 403 | Empresa suspendida o sin permisos. |
| 404 | Recurso no encontrado (webhook id inexistente, etc.). |
| 422 | Datos válidos pero estado incompatible (ventana 24h cerrada sin plantilla, variables de plantilla insuficientes, etc.). |
| 429 | Rate limit excedido. Espera y reintenta. |
| 500 | Error interno. Reintenta; si persiste, contacta soporte. |
Códigos de error
El campo error trae un código estable — escribe tu código contra él, no contra el texto de message, que puede cambiar.
| Código | HTTP | Qué pasó y qué hacer |
|---|---|---|
| window_expired | 422 | La ventana de 24 h está cerrada (o es el primer mensaje) y mandaste solo text. Reintenta con template. La respuesta incluye un hint con la forma del body correcto. |
| template_variables_mismatch | 422 | La plantilla espera otra cantidad de variables en el body. La respuesta dice cuántas espera (expected_variables) y cuántas mandaste. No se envió nada. |
| channel_not_bound | 422 | La key se quedó sin canal de WhatsApp asignado. Lo arregla el admin desde el panel. |
| bound_channel_inactive | 422 | El canal asignado a la key está desactivado. |
| canal_sin_token | 422 | El canal no tiene token de Meta configurado. |
| auth_template_not_supported_for_user_id | 422 | Mandaste una plantilla de autenticación a un identificador de usuario. WhatsApp solo las entrega a un número. No se envió nada. |
| combo_blocked | 422 | El pedido trae un combo que no se puede vender: mezcla productos de plataformas o cuentas de despacho distintas. Se corrige en el Catálogo; no se creó nada. |
| events_unknown | 422 | Suscribiste un evento que no existe. La respuesta trae available_events con el catálogo completo. |
/api/external/me
Verifica que tu API Key es válida y devuelve metadata del bot al que pertenece. Útil para chequear conectividad al setup de tu integración.
Ejemplo
curl https://panel.botwaip.com/api/external/me \ -H "X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Respuesta 200
{
"bot_id": "uuid",
"bot_name": "Mi Tienda",
"company_id": "uuid",
"company_name": "Mi Empresa S.A.S.",
"whatsapp_configured": true,
"phone_number_id": "1234567890",
"active": true
}
/api/external/messages/send
Envía un mensaje de WhatsApp a un contacto desde tu sistema. Maneja automáticamente la ventana de 24h de Meta:
- Ventana abierta (el cliente escribió en las últimas 24h) → envía texto plano.
- Ventana cerrada o primer contacto → exige una plantilla aprobada en Meta.
Parámetros del body
| Campo | Tipo | Descripción |
|---|---|---|
| toobligatorio | string | A quién le escribes. Un teléfono con código de país (+57 300 123 4567 o 573001234567) o el identificador de usuario de quien no enseña su número (CO.2196664141062187). Ver a quién escribes. |
| name | string | Nombre del contacto. Se guarda solo si es nuevo, no sobreescribe si ya existe. |
| text | string | Mensaje de texto plano. Solo válido si la ventana de 24h está abierta. Máximo 4096 caracteres. |
| template | object | Plantilla aprobada en Meta. Ver sección de plantillas. |
| force_template | boolean | Si es true, usa la plantilla aunque la ventana esté abierta. Default false. |
| on_reply | "ai" | "queue" | "close" | Qué hacer cuando el cliente responda. Default "ai". Ver sección de on_reply. |
| external_ref | string | Id arbitrario para trazabilidad (ej. "shopify-order-1234"). Se guarda en metadata de la conversación. |
Ejemplo: mensaje de texto (ventana abierta)
curl -X POST https://panel.botwaip.com/api/external/messages/send \
-H "X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"to": "573001234567",
"text": "Tu pedido #1234 fue despachado y llega mañana."
}'
Respuesta 201
{
"success": true,
"mode": "text",
"window_open": true,
"on_reply": "ai",
"message": { ... },
"conversation_id": "uuid",
"contact_id": "uuid",
"wa_id": "573001234567"
}
mode te dice por dónde salió: "text" o "template". Cuando sale por plantilla, window_open puede venir en true — eso pasa si forzaste la plantilla con force_template o si mandaste template teniendo la ventana abierta.
Guarda el conversation_id: es el mismo que vas a recibir después en los webhooks, así que es la clave para cruzar lo que enviaste con lo que el cliente responda.
Plantillas
Las plantillas se crean y aprueban en Meta Business Manager, luego aparecen en BotwAIp automáticamente. Para usarlas vía API:
Formato simplificado
{
"to": "573001234567",
"name": "Carlos López",
"template": {
"name": "pedido_despachado",
"language": "es",
"variables": ["Carlos", "1234", "23/05/2026"]
}
}
El array variables se mapea en orden a las variables del body de la plantilla ({{1}}, {{2}}, etc.). Si la plantilla tiene 3 variables y mandas 2 (o 4), la API devuelve 422 template_variables_mismatch sin enviar nada.
Formato avanzado (con header de imagen, botones, etc.)
Pasa los componentes de Meta directamente:
{
"to": "573001234567",
"template": {
"name": "promo_con_imagen",
"language": "es",
"variables": [
{
"type": "header",
"parameters": [
{ "type": "image", "image": { "link": "https://tu-cdn.com/banner.jpg" } }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Carlos" },
{ "type": "text", "text": "20%" }
]
}
]
}
}
A quién escribes: número o nombre de usuario
WhatsApp ya no es solo números. Desde que existen los nombres de usuario, mucha gente escribe a un negocio sin enseñar su teléfono, y Meta lo identifica con un BSUID — un identificador con la forma CO.2196664141062187: dos letras de país, un punto y una ristra de caracteres.
Esa persona es un contacto normal: aparece en el panel, la IA le responde y un agente puede atenderla. Lo único distinto es cómo se le escribe. El campo to acepta las dos identidades y nosotros resolvemos el resto.
// Por teléfono, como siempre
{ "to": "573001234567", "text": "Tu pedido va en camino." }
// Por nombre de usuario — mismo endpoint, mismo cuerpo
{ "to": "CO.2196664141062187", "text": "Tu pedido va en camino." }
De dónde sacas ese identificador
De los webhooks. En contact.wa_id viene la identidad con la que Meta conoce a esa persona: un número si lo enseña, y el BSUID si no. Guárdalo tal cual lo recibes y devuélvelo igual en to. No hace falta que sepas cuál de los dos es.
Es la misma persona aunque cambie de identidad
El teléfono solo viaja en el webhook si hubo contacto en los últimos 30 días o si la persona te tiene agendado. Por eso el mismo cliente puede llegar unas veces como número y otras como CO.219…. Nosotros unimos las dos en la misma ficha, y el día que aparece el teléfono la ficha se queda con el número sin perder el historial. Tu sistema no tiene que hacer nada; solo no asumir que wa_id es siempre un teléfono.
Lo que cambia cuando el destinatario es un usuario
| Qué | Con teléfono | Con identificador de usuario |
|---|---|---|
| Texto y plantillas de utilidad o marketing | Sí | Sí, igual |
| Plantillas de autenticación (códigos de un solo uso) | Sí | No. WhatsApp no las entrega a un usuario: exige un número. |
Campo phone del contacto | El número | Vacío, hasta que esa persona enseñe el teléfono |
| Pedidos, guías de envío, facturación | Funcionan | Necesitan el teléfono: pídeselo en la conversación |
Si mandas una plantilla de autenticación a un identificador de usuario, cortamos el envío con 422 auth_template_not_supported_for_user_id y te decimos por qué. Es a propósito: WhatsApp aceptaría la petición y te devolvería un identificador de mensaje como si todo hubiera salido bien, y el código no llegaría nunca. Preferimos que te enteres al llamar y no cuando el cliente se queje.
¿Y si solo tengo su @usuario?
Entonces no puedes escribirle, y no es una limitación nuestra. El @usuario sirve para que las personas se encuentren dentro de la app; la API de WhatsApp no lo admite como destinatario. Solo hay dos formas de llegar a alguien: su teléfono, o el identificador que Meta te da después de que esa persona te escriba.
| Lo que tienes | ¿Puedes escribirle? |
|---|---|
| Su teléfono | Sí. Con plantilla si hace más de 24 h que no se escriben. |
| Su identificador de usuario, porque ya te escribió | Sí, menos plantillas de autenticación. |
Solo su @usuario | No. Te devolvemos 400 explicándolo. |
Dicho de otra forma: con los clientes que ocultan el número, la primera palabra siempre la tienen ellos. Tú puedes responder y seguir escribiéndoles todo lo que quieras, pero no empezar la conversación. Si lo que necesitas es poder buscarlos tú, pídeles el teléfono durante la conversación — el bot lo puede preguntar y guardar.
Si alguien te escribe y tiene nombre de usuario, lo verás como su nombre en el panel cuando no traiga otro. Es para que lo reconozcas, no una dirección a la que mandar.
Mayúsculas
La documentación de Meta los escribe en mayúscula (CO.219…) pero en la práctica llegan en minúscula (co.219…). Mándalo como te llegue: buscamos el contacto sin distinguir mayúsculas y usamos la identidad que ya tenemos guardada, para no abrir una ficha duplicada ni escribirle a un identificador que Meta no reconozca.
Política on_reply
Decide qué pasa cuando el cliente RESPONDA a una notificación enviada vía API. Tres opciones:
| Valor | Comportamiento | Cuándo usarlo |
|---|---|---|
| "ai" | El asistente IA del bot responde normalmente. | Default. Notificaciones donde el cliente puede continuar la conversación con el bot. |
| "queue" | La conversación entra a cola humana. La IA no responde, un agente del panel la toma. | Cobros, soporte VIP, casos donde la respuesta requiere intervención humana. |
| "close" | El bot no responde. El mensaje queda guardado en el panel con nota interna. | Avisos unidireccionales (recordatorios, confirmaciones automáticas) donde no se espera diálogo. |
El valor se persiste en la conversación y aplica a todos los mensajes entrantes hasta que un agente lo cambie manualmente, o hasta que envíes otra notificación con un on_reply distinto (la última llamada gana).
Webhooks salientes
Tu servidor recibe un POST en tiempo real cuando ocurre un evento en BotwAIp. Útil para sincronizar mensajes entrantes con tu CRM, alertar a tu sistema cuando una conversación se cierra, etc.
Cómo suscribirse
Hay dos caminos y hacen lo mismo. Desde el panel, si lo configuras una vez y te olvidas; desde la API, si tu producto instala la integración solo en casa de cada cliente.
- Entra a Configuración → Integraciones de API en el panel.
- Abre la API Key del sistema que va a recibir los eventos.
- Pulsa "Nuevo webhook", pega tu URL y selecciona los eventos que quieres recibir.
- Al guardar, copia el secret que se muestra (es para validar HMAC; no se vuelve a mostrar).
O sin abrir el panel: gestiónalos desde tu código con la misma API Key.
Formato del POST
POST https://tu-sistema.com/webhook HTTP/1.1
Content-Type: application/json
X-Webhook-Event: message.inbound
X-Webhook-Signature: sha256=ab12cd34...
User-Agent: BotwAIp-Webhooks/1.0
{
"event": "message.inbound",
"timestamp": "2026-05-22T20:36:45.123Z",
"bot_id": "uuid",
"data": {
"conversation_id": "uuid",
"contact": { "id": "uuid", "wa_id": "573001234567", "name": "Carlos" },
"channel": "whatsapp",
"message": { "type": "text", "text": "Hola, quiero más info" },
"is_new_conversation": false
}
}
Tu endpoint debe responder con 2xx en menos de 8 segundos. Cualquier otra respuesta cuenta como fallo y se reintentará.
Alcance (scope) del webhook
Al crear el webhook eliges qué conversaciones generan eventos para tu URL:
| Scope | Comportamiento | Cuándo usarlo |
|---|---|---|
| all | Recibe eventos de TODAS las conversaciones del bot: orgánicas (cliente escribió directo), originadas por esta API key, y originadas por otras API keys del mismo bot. | Default. CRMs y sistemas que quieren registrar todo contacto con clientes. |
| api_origin | Solo recibe eventos de conversaciones que esta misma API key originó vía /messages/send. Ignora orgánicas y de otras keys. |
Automatizaciones narrow que solo trackean sus propias notificaciones (ej. "avisar al ERP cuando el cliente responda al recordatorio de pago"). |
El "origen" de una conversación se determina al primer mensaje saliente vía /messages/send — esa API Key queda registrada como la que originó la conv (campo api_origin_key_id en metadata). Si otra API Key envía mensajes después en la misma conversación, el origen sigue siendo la primera (no se sobreescribe).
Nota sobre conversaciones orgánicas
Si configuras scope='api_origin' pero un cliente escribe al WhatsApp del bot sin que hayas enviado primero una notificación, ese mensaje NO te llegará por webhook. Es el comportamiento esperado — la conversación no tiene origen API. Si quieres capturar también ese caso, usa scope='all'.
Validación HMAC
Cada POST incluye X-Webhook-Signature: sha256=<hmac>. El HMAC se calcula con SHA-256 sobre el body raw usando el secret de la suscripción. Valida la firma antes de procesar para asegurarte que el POST realmente viene de BotwAIp.
Node.js / Express
import crypto from 'crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.BOTWAIP_WEBHOOK_SECRET;
// IMPORTANTE: usar raw body, no JSON parsed
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (req.headers['x-webhook-signature'] !== expected) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
console.log('Evento recibido:', event.event, event.data);
res.status(200).end();
});
Python / Flask
import hmac, hashlib
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ['BOTWAIP_WEBHOOK_SECRET']
@app.post('/webhook')
def webhook():
body = request.get_data() # raw bytes
expected = 'sha256=' + hmac.new(
SECRET.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(request.headers.get('X-Webhook-Signature', ''), expected):
return '', 401
event = request.get_json()
print('Evento:', event['event'], event['data'])
return '', 200
Tip de seguridad: usa comparación constante (hmac.compare_digest en Python, crypto.timingSafeEqual en Node) para prevenir timing attacks.
Catálogo de eventos
Selecciona los eventos que quieres recibir al crear la suscripción. Estos son los disponibles:
message.inbound
Un cliente envió un mensaje al bot (texto, audio, imagen, etc.).
contact.wa_id no es siempre un teléfono: es la identidad con la que Meta conoce a esa persona, y puede ser un identificador de usuario o el token del Web Chat. Devuélvelo tal cual en to y llega a quien tiene que llegar.
Ver payload
{
"conversation_id": "uuid",
"contact": { "id": "uuid", "wa_id": "57...", "name": "Carlos", "phone": "57..." },
"channel": "whatsapp",
"message": {
"wa_message_id": "wamid.xxx",
"type": "text",
"text": "Hola"
},
"is_new_conversation": false
}
message.outbound.sent
El bot, un agente o tu propia API enviaron un mensaje saliente. Útil para reflejar la salida en tu CRM sin polling.
Ver payload
{
"conversation_id": "uuid",
"contact_id": "uuid",
"wa_id": "57...",
"channel": "whatsapp",
"mode": "template",
"template_name": "pedido_despachado",
"external_ref": "shopify-order-1234"
}
conversation.opened
Se creó una conversación nueva (primer contacto del cliente o nueva tras una cerrada).
conversation.queued
La conversación entró a cola humana (cliente pidió agente, on_reply=queue, etc.). Útil para alertar a tu sistema de tickets externo.
conversation.assigned
Un agente humano fue asignado a la conversación (automático o manual desde el panel).
conversation.closed
La conversación se cerró manualmente o por inactividad. El reason indica cuál.
Reintentos y auto-desactivación
Si tu endpoint responde con un código distinto de 2xx, o tarda más de 8 segundos, BotwAIp reintenta hasta 3 veces con backoff exponencial:
- 1° reintento: 1 segundo después
- 2° reintento: 4 segundos después
- 3° reintento: 10 segundos después
Si los 4 intentos fallan, se incrementa failures_count de la suscripción y se guarda last_error. Tras 20 fallos consecutivos, la suscripción se desactiva automáticamente para no consumir recursos contra un endpoint caído.
Para reactivar, entra al panel, abre la API Key y pulsa el botón de reanudar del webhook. También puedes hacerlo desde tu propio código con un PATCH. En los dos casos el contador de fallos vuelve a cero.
Consejos para endpoints estables
- Responde 2xx inmediatamente, antes de procesar el evento. Procesa en background.
- Tu endpoint debe ser idempotente — el mismo evento puede llegar más de una vez si hubo un timeout entre los reintentos.
- Usa el
wa_message_id(para mensajes) oconversation_id + timestampcomo clave de deduplicación.
Gestionar webhooks desde tu código
Todo lo que haces en el panel con los webhooks lo puedes hacer con la misma API Key: crearlos, listarlos, ver por qué fallan, apagarlos y borrarlos. Pensado para productos que se instalan solos en la cuenta de cada cliente, sin pedirle que entre a configurar nada.
| Endpoint | Para qué |
|---|---|
| GET /webhooks/events | El catálogo de eventos que puedes suscribir, con su descripción. |
| GET /webhooks | Tus suscripciones, con el estado de las últimas entregas. |
| POST /webhooks | Crear una suscripción. Devuelve el secret una sola vez. |
| PATCH /webhooks/:id | Cambiar URL, eventos, alcance, descripción; apagar o reactivar. |
| DELETE /webhooks/:id | Borrarla definitivamente. |
Cada key ve solo lo suyo
Estos endpoints trabajan siempre sobre los webhooks de la API Key con la que llamas. Si el mismo bot tiene otra key (tu ERP, por ejemplo), no vas a ver ni tocar sus suscripciones — y ella tampoco las tuyas. Un id de webhook de otra key devuelve 404, no 403.
Descubrir los eventos disponibles
/api/external/webhooks/events
Devuelve el catálogo vivo. Si mañana agregamos un evento, aparece aquí sin que tengas que volver a esta página — útil para pintar la lista de checkboxes en tu propia interfaz.
curl https://panel.botwaip.com/api/external/webhooks/events \
-H "X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
"events": [
{ "name": "message.inbound", "description": "El cliente envió un mensaje al bot. Payload: { conversation_id, contact, message }." },
{ "name": "message.outbound.sent", "description": "El bot/sistema externo envió un mensaje saliente. ..." },
{ "name": "conversation.opened", "description": "Se creó una nueva conversación con un cliente. ..." },
{ "name": "conversation.queued", "description": "La conversación entró a cola humana. ..." },
{ "name": "conversation.assigned", "description": "Un agente humano fue asignado a la conversación. ..." },
{ "name": "conversation.closed", "description": "La conversación fue cerrada. ..." }
]
}
/api/external/webhooks
Crea una suscripción y devuelve el secret con el que vas a validar la firma de cada entrega. Es la única vez que lo vas a ver: guárdalo en ese mismo paso.
Parámetros del body
| Campo | Tipo | Descripción |
|---|---|---|
| urlobligatorio | string | A dónde mandamos el POST. Con protocolo (https://…) y máximo 2048 caracteres. |
| eventsobligatorio | string[] | Al menos uno del catálogo. Si mandas uno que no existe, la petición falla entera con 422 events_unknown y no se crea nada. |
| scope | "all" | "api_origin" | Qué conversaciones te generan eventos. Default "all". Ver alcance. |
| description | string | Para ti y para quien mire el panel después. Máximo 200 caracteres. |
Ejemplo
curl -X POST https://panel.botwaip.com/api/external/webhooks \
-H "X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://tu-sistema.com/hooks/botwaip",
"events": ["message.inbound", "conversation.closed"],
"scope": "api_origin",
"description": "Respuestas a los recordatorios de pago"
}'
Respuesta 201
{
"id": "uuid",
"url": "https://tu-sistema.com/hooks/botwaip",
"secret": "whsec_3f9a…", // guárdalo ahora: no se muestra otra vez
"events": ["message.inbound", "conversation.closed"],
"scope": "api_origin",
"active": true,
"description": "Respuestas a los recordatorios de pago",
"created_at": "2026-05-22T20:36:45.123Z",
"warning": "Guarda este `secret` ahora — no se mostrará de nuevo. …"
}
Si pierdes el secret no hay forma de recuperarlo: borra la suscripción y crea otra. Mientras tanto sigues recibiendo entregas que no vas a poder validar, así que hazlo en el mismo despliegue.
/api/external/webhooks
Tus suscripciones, de la más nueva a la más vieja. Además de la configuración, cada una trae el estado de las entregas — con eso puedes montar tu propia pantalla de diagnóstico sin pedirle a nadie que entre al panel.
{
"webhooks": [
{
"id": "uuid",
"url": "https://tu-sistema.com/hooks/botwaip",
"events": ["message.inbound"],
"scope": "all",
"active": true,
"description": "CRM",
"failures_count": 0, // fallos seguidos; a los 20 se apaga sola
"last_status": 200, // último código HTTP que respondiste
"last_delivered_at": "2026-05-22T20:36:45.123Z", // última entrega con 2xx
"last_error": null, // qué falló la última vez
"created_at": "2026-05-01T10:00:00.000Z"
}
],
"api_key": { "id": "uuid", "name": "CRM HubSpot" }
}
El secret no viene aquí, ni en ningún otro endpoint: solo se devuelve al crear la suscripción.
Cómo leer los tres campos de estado
failures_count: 0ylast_status: 200— todo bien.failures_countsubiendo ylast_errorcon texto — tu endpoint está respondiendo mal o tardando más de 8 segundos.active: falseconfailures_count: 20— la apagamos nosotros. Reactívala con unPATCH.last_delivered_at: nullen una suscripción vieja — nunca llegó nada: revisa que el evento que suscribiste de verdad ocurra, y que elscopeno lo esté filtrando.
/api/external/webhooks/:id
Manda solo los campos que quieres cambiar: url, events, scope, active o description. Un body vacío devuelve 400. events se reemplaza entero, no se suma: manda la lista completa que quieres a partir de ahora.
# Pausar sin perder la configuración ni el secret
curl -X PATCH https://panel.botwaip.com/api/external/webhooks/<id> \
-H "X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "active": false }'
# Mudar el endpoint y volver a encenderlo
curl -X PATCH https://panel.botwaip.com/api/external/webhooks/<id> \
-H "X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "url": "https://nuevo.tu-sistema.com/hooks", "active": true }'
Reactivar borra el historial de fallos
Mandar active: true pone failures_count en cero y limpia last_error. Es lo que quieres después de arreglar tu endpoint: la suscripción arranca de nuevo con los 20 intentos completos por delante. Apagar con active: false no toca el contador.
Responde con la suscripción ya actualizada, en el mismo formato que GET /webhooks.
/api/external/webhooks/:id
Borra la suscripción. Responde 204 sin cuerpo, y 404 si ese id no es de esta API Key. No hay papelera: si solo quieres dejar de recibir un rato, usa active: false.
curl -X DELETE https://panel.botwaip.com/api/external/webhooks/<id> \ -H "X-Bot-Api-Key: bwk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Web Chat
El Web Chat es el canal del navegador: el mismo bot que atiende por WhatsApp, atendiendo en tu página. Las conversaciones llegan a la misma bandeja, la IA responde igual y un agente puede tomarlas igual.
Hay dos formas de usarlo, y la mayoría solo necesita la primera.
| Si quieres… | Haz esto |
|---|---|
| El chat listo en tu web. Burbuja, ventana, subida de archivos, formulario previo — ya hecho. | Copia el snippet que el panel te arma en Canales → Web Chat, ya con tu clave puesta. No necesitas nada de esta página. |
| Tu propia interfaz de chat. Dentro de tu app móvil, de un kiosco, de un componente que ya tienes diseñado. | Habla directo con la API del canal. Es la parte que se documenta más abajo. |
La site key no es una API Key
El Web Chat no usa X-Bot-Api-Key. Se identifica con la site key del canal, que viaja en la query como key y es pública por diseño: está en el HTML de tu página, cualquiera que mire el código fuente la ve. Eso es correcto y no hay nada que proteger ahí, pero cambia lo que puedes esperar de ella.
| API Key bwk_… | Site key del Web Chat | |
|---|---|---|
| Dónde vive | En tu servidor, en secreto. | En el HTML público de tu web. |
| Qué permite | Escribirle a cualquier contacto. | Abrir una conversación nueva como visitante. |
| Quién la usa | Tu backend. | El navegador de quien te visita. |
| Si se filtra | Revócala ya. | No pasa nada: ya era pública. |
Los «dominios permitidos» no cierran la API
La lista de dominios del panel controla quién puede embeber tu widget (va como frame-ancestors en la cabecera del iframe). La API del canal sigue respondiendo a cualquier origen — tiene que hacerlo, porque también la usan las integraciones propias. Lo que limita el abuso es el tope de conversaciones nuevas por hora, no la lista de dominios.
Identidad verificada
Si tu sitio ya tiene al usuario con sesión iniciada, puedes pasárselo al chat firmado desde tu servidor. Es la única parte del Web Chat que exige que escribas código de backend, y resuelve dos cosas a la vez.
- Nadie puede suplantar a nadie. Sin firma, decir «soy carlos@empresa.com» es escribir un texto; con firma hay que conocer tu secreto.
- La conversación lo sigue. El mismo usuario en otro navegador o en el móvil retoma su hilo, en vez de empezar de cero.
Actívala en Canales → Web Chat → Identidad verificada. El panel genera el secreto y te arma el snippet con tu clave; esto es la explicación de qué hace por debajo.
Cómo se firma
En tu servidor, HMAC-SHA256 del identificador con el secreto, en hexadecimal minúscula. El identificador es user.id si lo mandas, y si no user.email — firma exactamente el mismo valor que vas a enviar.
// Node — en tu servidor, nunca en el navegador
const hash = crypto.createHmac('sha256', WEBCHAT_SECRET)
.update(String(user.id))
.digest('hex');
# Python
hash = hmac.new(SECRET.encode(), str(user.id).encode(), hashlib.sha256).hexdigest()
# PHP
$hash = hash_hmac('sha256', (string) $user->id, $secret);
El secreto no sale de tu servidor. Si lo pones en el JavaScript de la página, cualquiera puede firmar cualquier identidad y la verificación deja de servir para algo.
Qué pasa con el hash
Lo mandas a POST /session junto con los datos del usuario. Si cuadra, el token del visitante pasa a ser el propio hash: determinista, así que el mismo usuario cae siempre en el mismo contacto, e infalsificable sin el secreto.
POST /api/webchat/session
{
"key": "<site key>",
"visitor_token": "el-que-tengas-guardado",
"user": { "id": "4812", "name": "Carlos López", "email": "carlos@empresa.com" },
"hash": "3f9a…"
}
→ { "token": "3f9a…", "resumed": true, "verified": true }
Si el hash no cuadra no devolvemos error: seguimos adelante como visitante anónimo, con su propio token. Un fallo de firma no deja a nadie sin poder escribir.
Sin firma no se retoma por correo
Mandar un email que coincida con un contacto existente no te devuelve su conversación, aunque parezca lo cómodo. Si lo hiciera, conocer el correo de alguien bastaría para leer y escribir en su hilo. Sin identidad verificada, cada navegador es un visitante nuevo y la vuelta se resuelve con el token que guardaste en localStorage.
Construir tu propio cliente
Todo bajo https://panel.botwaip.com/api/webchat. Sin cabecera de autenticación: la identifican la key del canal y el visitor_token del visitante. Responde a cualquier origen y no usa cookies, así que sirve desde un navegador, desde una app móvil o desde un kiosco.
El visitor_token es la llave de esa conversación
Lo generas tú (8 a 100 caracteres de A-Z a-z 0-9 _ -; un UUID va bien) y quien lo tenga puede leer y escribir en el hilo de ese visitante. Guárdalo donde solo llegue ese cliente — localStorage en web, el almacén seguro en una app— y no lo pongas nunca en una URL que se pueda compartir o que acabe en un log.
| Endpoint | Para qué | Límite |
|---|---|---|
| GET /config | Apariencia, formulario previo, mensaje de fuera de horario y si el canal está encendido. | 60/min |
| POST /session | Abre o retoma la sesión del visitante. Aquí va la identidad firmada. | 20/min |
| POST /message | Lo que escribe el visitante. | 30/min |
| GET /stream | Lo que le contestan, según llega. Es SSE. | 30/min |
| GET /history | El hilo, para pintarlo al volver o tras un corte. | 60/min |
| POST /upload | Sube un archivo y te devuelve la URL para adjuntarla. | 15/min |
| POST /close | El visitante da la conversación por terminada. | 20/min |
El ciclo completo
// 1. Cómo se tiene que ver el chat, y si el canal está encendido.
const cfg = await fetch(`${BASE}/config?key=${KEY}`).then(r => r.json());
if (!cfg.active) mostrarMensajeDeCerrado(cfg.offline_message);
// 2. Un token estable para este visitante.
let token = localStorage.getItem('wc_token');
if (!token) { token = crypto.randomUUID(); localStorage.setItem('wc_token', token); }
const ses = await fetch(`${BASE}/session`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: KEY, visitor_token: token /*, user, hash */ }),
}).then(r => r.json());
token = ses.token; // con identidad verificada, cambia por el hash
// 3. Lo que ya se habló (vacío si es la primera vez).
const { messages } = await fetch(`${BASE}/history?key=${KEY}&token=${token}`).then(r => r.json());
// 4. Escuchar lo que le contesten, según llega.
const es = new EventSource(`${BASE}/stream?key=${KEY}&token=${token}`);
es.onmessage = (e) => pintar(JSON.parse(e.data));
// 5. Y mandar lo que escriba.
await fetch(`${BASE}/message`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: KEY, visitor_token: token, text: 'Hola', page_url: location.href }),
});
Mandar un mensaje
| Campo | Tipo | Descripción |
|---|---|---|
| keyobligatorio | string | La site key del canal. |
| visitor_tokenobligatorio | string | El token de este visitante. |
| text | string | Hasta 4096 caracteres. |
| message_type | "text" | "image" | "audio" | "video" | "document" | Para adjuntos. Va junto con media. |
| media | object | { url, mime, filename }. La url tiene que ser http(s) — la que te devolvió /upload. |
| message_id | uuid | Tuyo, para que un reintento no duplique el mensaje. |
| name, email, phone | string | Quién es, si lo sabes. Se los ve el agente en el panel. |
| prechat | object | Los campos de tu formulario previo, como { "Nº de pedido": "1234" }. |
| page_url, referrer, user_agent | string | Contexto para el agente: desde qué página escribe. |
Recibir las respuestas
GET /stream es un server-sent events normal: cada respuesta del bot o del agente llega como un data: con el mensaje en JSON. Cada 25 segundos va un comentario de mantenimiento para que ningún proxy corte la conexión por inactividad.
: connected
id: wamid.xxx
data: {"id":"…","direction":"outbound","sender_type":"bot","type":"text","text":"¡Hola! ¿En qué te ayudo?","url":null,"filename":null,"created_at":"2026-05-22T20:36:45.123Z"}
: ping
Si se corta la conexión, vuelve a abrirla y rellena el hueco con /history?since=<ISO del último mensaje>, que devuelve solo lo posterior. Sin since trae los últimos 200 del hilo abierto. Las notas internas del equipo nunca salen por aquí.
Adjuntar un archivo
Son dos pasos: subir y después mandar el mensaje con lo que te devolvió la subida.
// multipart/form-data con un solo campo de archivo
const fd = new FormData(); fd.append('file', archivo);
const m = await fetch(`${BASE}/upload?key=${KEY}&token=${token}`, { method: 'POST', body: fd })
.then(r => r.json());
// → { "url": "https://…", "mime": "image/png", "filename": "captura.png", "message_type": "image" }
await fetch(`${BASE}/message`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: KEY, visitor_token: token, message_type: m.message_type, media: m }),
});
Los tipos que no están en la lista permitida se rechazan con 415, y pasarse de tamaño con 413: imagen 5 MB, audio 16 MB, documento 20 MB, vídeo 64 MB.
Límites del Web Chat
Como la clave es pública, los topes van por ruta y por canal, no por cliente. Son altos para un visitante y bajos para quien quiera abusar.
- Los de cada ruta, en la tabla de arriba. Al pasarse,
429. - 120 conversaciones nuevas por hora y por canal. Los visitantes que ya conocemos no cuentan, así que una charla larga nunca choca con esto; lo que frena es a quien intente abrir miles de hilos vacíos.
- Un mensaje, hasta 4096 caracteres. El historial, los últimos 200.
- Si el canal está apagado,
/configdevuelveactive: falsecon tu mensaje de fuera de horario. Súbelo a tu interfaz en vez de dejar que el visitante escriba a una puerta cerrada.
Las conversaciones del Web Chat también disparan webhooks, con channel: "webchat". Si ya escuchas los eventos del bot, estas te llegan por el mismo sitio sin tocar nada.
Un detalle al cruzarlas con tus datos: en un contacto de Web Chat el campo contact.wa_id no es un teléfono, es el visitor_token. Con identidad verificada ese token es el hash de tu propio identificador de usuario, así que puedes volver de ahí a la ficha que tienes en tu sistema.
Pedidos y catálogo
Tu sistema (tienda, ERP, CRM) puede crear pedidos en BotwAIp. El pedido aparece en Pedidos como cualquier otro, BotwAIp le escribe al cliente por WhatsApp para confirmarlo —igual que con los pedidos de Shopify—, lo sube a tu plataforma de envíos si el bot lo tiene activado y te avisa por webhook de cada cambio.
Permisos de la llave. Cada API Key tiene permisos: messages, orders y catalog.
Se activan en el panel (Configuración del bot → API y webhooks). Sin el permiso la respuesta es 403 scope_required.
GET /me te dice cuáles tiene la tuya.
- Lee el catálogo para conocer los SKU (
GET /catalog). - Crea el pedido con tu propia referencia (
POST /orders). Reintentar con la misma referencia no lo duplica. - El cliente confirma o cancela por WhatsApp; te llega
order.confirmeduorder.cancelled. - Carga la guía cuando la tengas (
PATCH /orders/:id) y el cliente recibe el aviso.
/api/external/catalog?q=&page=1&limit=50
permiso catalog
Productos disponibles del bot, con SKU, precio, categoría, nicho y variantes. q busca por nombre o SKU; limit máximo 100.
{
"items": [
{ "id": "uuid", "type": "product", "name": "Colágeno hidrolizado", "sku": "COL-500",
"price": 89900, "currency": "COP", "category": "Salud", "niche": "Suplementos",
"variants": [ { "id": "uuid", "name": "Fresa", "sku": "COL-500-FR", "price": 89900, "stock": 12 } ] }
],
"page": 1, "limit": 50, "total": 1, "pages": 1
}/api/external/orders
permiso orders
Crea el pedido y le escribe al cliente por el canal de la llave. Responde 201; si la referencia ya existía, 200 con "duplicate": true y el mismo pedido.
| Campo | Tipo | Descripción |
|---|---|---|
| external_refobligatorio | string | Tu número de pedido. Hace la llamada idempotente. |
| customer.phoneobligatorio | string | WhatsApp del cliente con indicativo (573001234567). Si llega sin indicativo se usa el del país del bot. |
| customer.name / email / document | string | Datos del cliente (opcionales). |
| items[]obligatorio | array | Productos: sku, catalog_item_id, catalog_variant_id o name; qty obligatorio; unit_price opcional (vale el que mandes; si no, el del catálogo). Un combo se guarda como los productos que lo forman, con el precio del combo repartido entre ellos y el total exacto; cada línea trae combo_id y combo_name. |
| shipping_amount | number | Envío cobrado al cliente. |
| total | number | Total a cobrar. Si no viene: suma de precio × cantidad + envío. |
| payment_method | "cod" | "paid" | Contra entrega (por defecto) o ya pagado. |
| delivery | object | address, neighborhood, city, state, postal_code, notes. |
| notes | string | Notas del pedido. |
| confirmation | "ask" | "notify" | "none" | Qué recibe el cliente. Por defecto ask. Ver confirmación. |
| report_to_meta | boolean | Reportar la venta a Meta (Conversions API). Por defecto false: si tu tienda ya reporta sus compras, se contaría dos veces. |
| payment_link | boolean | Genera el link de pago con tu pasarela conectada (Bold, Mercado Pago, ePayco o dLocal Go, según cómo cobre el bot) y se lo envía al cliente. |
| payment_providers | string[] | Opcional. Con qué pasarela(s) cobrar: bold, mercadopago, epayco y/o dlocalgo. Sin él, manda la configuración del bot. |
curl -X POST https://TU-DOMINIO/api/external/orders \
-H 'X-Bot-Api-Key: bwk_...' -H 'Content-Type: application/json' \
-d '{
"external_ref": "PED-1024",
"customer": { "phone": "573001234567", "name": "María Gómez" },
"items": [ { "sku": "COL-500", "qty": 2 } ],
"shipping_amount": 10000,
"delivery": { "address": "Cra 5 # 10-20", "city": "Bogotá", "state": "Cundinamarca" },
"confirmation": "ask"
}'{
"duplicate": false,
"order": {
"id": "uuid", "order_number": 1024, "external_ref": "PED-1024", "status": "pending",
"confirmation": "pending", "total": 189800, "currency": "COP",
"payment": { "method": "cod", "status": "cod", "link": null },
"shipment": { "carrier": null, "tracking_number": null, "status": null },
"dispatched_to": null
},
"confirmation": { "mode": "ask", "sent": true, "method": "flow" },
"unmatched_items": []
}unmatched_items lista los productos que no se pudieron vincular con el catálogo: el pedido se crea igual, pero esos productos no se suben solos a la plataforma de envíos.
Confirmación al cliente
- ask (por defecto): le llega el resumen con Confirmar, Cancelar y Modificar datos —como en los pedidos de Shopify—. Al confirmar, el pedido pasa a
confirmed, se sube a la plataforma de envíos (si el bot lo tiene activado) y te llegaorder.confirmed. Si cancela,order.cancelled. Si pide modificar, lo atiende un asesor. - notify: solo «tu pedido quedó registrado». El pedido se sube a envíos de inmediato.
- none: no se le escribe. El pedido se sube a envíos de inmediato.
Si el cliente no ha escrito en las últimas 24 horas, WhatsApp exige plantilla: ask usa btw_confirm_order_shp y notify usa pedido_creado, que deben estar aprobadas en tu cuenta de WhatsApp (Plantillas). Si el mensaje no se pudo enviar, la respuesta trae "sent": false y el motivo, y el pedido queda creado.
/api/external/orders/:id
o
/api/external/orders?external_ref=PED-1024
El pedido con su estado, pago (payment.status: cod, awaiting, paid, expired), envío (shipment: transportadora, guía, estado) y a qué plataforma se subió. :id acepta el id de BotwAIp o tu external_ref.
/api/external/orders/:id
| Campo | Tipo | Descripción |
|---|---|---|
| status | "confirmed" | "cancelled" | Confirmar (lo sube a envíos si estaba esperando confirmación) o cancelar. No se puede cancelar un pedido con guía. |
| shipment | object | carrier, tracking_number, label_url. La primera guía le envía al cliente el aviso «guía generada». |
| notes | string | Reemplaza las notas del pedido. |
{ "shipment": { "carrier": "Interrapidísimo", "tracking_number": "240012345678", "label_url": "https://..." } }Eventos de pedidos para tus webhooks:
order.created, order.confirmed, order.cancelled, order.status_changed, order.shipping_updated y order.paid.
Todos traen el pedido completo en data.order, con la misma forma que GET /orders/:id. Con alcance api_origin solo recibes los de los pedidos creados con tu llave.
BotwAIp · Documentación de la API