El problema que queríamos resolver
Una clínica de estética en Bogotá nos contrató con una queja concreta: su recepcionista dedicaba más de 3 horas diarias a responder el mismo mensaje de WhatsApp — "¿Tienen citas disponibles para el viernes?" — y aun así los clientes quedaban esperando respuesta hasta por 2 horas. La solución obvia era un bot, pero los clientes de la clínica usan WhatsApp, no portales web ni apps.
Decidimos construir BotCitas en 5 días laborables. El bot debía entender lenguaje natural en español colombiano, conocer los servicios y horarios de la clínica, y agendar citas directamente en el calendario. Este artículo es la bitácora técnica de esa semana.
¿Qué API de WhatsApp usar?
Antes de escribir una línea de código hay que tomar la decisión más importante del proyecto. Existen dos rutas:
Meta Cloud API (oficial)
- ✓ Gratis hasta 1 000 conversaciones / mes
- ✓ Sin riesgo de baneo de cuenta
- ✓ Soporte para plantillas de salida
- ✓ SLA y documentación de Meta
- ✗ Aprobación de plantillas (24-48 h)
- ✗ Flujo de onboarding más largo
Baileys (no oficial)
- ✓ Listo en minutos con un QR
- ✓ Soporta grupos, stickers, etc.
- ✓ Sin aprobación de Meta
- ✗ Riesgo alto de baneo permanente
- ✗ Inestable ante actualizaciones de WA
- ✗ No apto para producción empresarial
Nuestra decisión: Meta Cloud API. Baileys es tentador para prototipos rápidos, pero para un negocio real el riesgo de perder el número de teléfono es inaceptable. El tier gratuito de Meta cubre perfectamente los primeros meses de un negocio colombiano pequeño.
Arquitectura del sistema
El flujo completo de un mensaje entrante tiene cinco pasos:
Usuario WA
│ mensaje de texto
▼
Meta Cloud API
│ POST /webhook (JSON con el mensaje)
▼
Next.js API Route /api/whatsapp/webhook
│ 1. Verifica firma X-Hub-Signature-256
│ 2. Extrae phone + texto
│ 3. Carga historial de PostgreSQL
▼
OpenRouter AI (modelo: meta-llama/llama-3.1-70b-instruct)
│ system prompt + historial + mensaje nuevo
│ respuesta en texto plano
▼
PostgreSQL
│ guarda turno usuario + turno asistente
▼
Meta Cloud API
│ POST /messages (respuesta al usuario)
▼
Usuario WA recibe la respuestaUsamos Next.js App Router porque ya teníamos el panel de administración en ese stack. PostgreSQL (Supabase) nos dio persistencia sin infraestructura adicional. OpenRouter nos permite cambiar de modelo sin tocar el código.
Configurar Meta Cloud API
El proceso de registro toma aproximadamente medio día la primera vez. Los pasos esenciales:
- Crear una app de tipo Business en developers.facebook.com y agregarle el producto WhatsApp.
- En la sección WhatsApp → Configuración de la API, copiar el Phone Number ID y generar un Token de acceso temporal (válido 24 h) o uno permanente via System User en Business Manager.
- Registrar la URL del webhook:
https://tudominio.com/api/whatsapp/webhook. Meta enviará un GET de verificación con unhub.challengeque debes responder. - Suscribirse al evento messages en la configuración del webhook.
- Guardar en variables de entorno:
WHATSAPP_TOKEN,WHATSAPP_PHONE_NUMBER_ID,WHATSAPP_VERIFY_TOKEN(un string secreto que tú defines),WHATSAPP_APP_SECRET.
Webhook en Next.js App Router
El archivo vive en app/api/whatsapp/webhook/route.ts. El GET maneja la verificación inicial de Meta; el POST procesa los mensajes entrantes.
// app/api/whatsapp/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { processIncomingMessage } from '@/lib/whatsapp/processor'
// Meta verifica el webhook con un GET al registrarlo
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const mode = searchParams.get('hub.mode')
const token = searchParams.get('hub.verify_token')
const challenge = searchParams.get('hub.challenge')
if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
return new NextResponse(challenge, { status: 200 })
}
return new NextResponse('Forbidden', { status: 403 })
}
// Meta envía los mensajes entrantes por POST
export async function POST(req: NextRequest) {
// 1. Verificar firma HMAC para asegurar que viene de Meta
const rawBody = await req.text()
const sigHeader = req.headers.get('x-hub-signature-256') ?? ''
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WHATSAPP_APP_SECRET!)
.update(rawBody)
.digest('hex')
if (sigHeader !== expected) {
return new NextResponse('Unauthorized', { status: 401 })
}
// 2. Parsear el payload
const body = JSON.parse(rawBody)
const entry = body.entry?.[0]
const changes = entry?.changes?.[0]
const value = changes?.value
// Ignorar notificaciones de estado (delivered, read, etc.)
if (!value?.messages?.length) {
return new NextResponse('OK', { status: 200 })
}
const message = value.messages[0]
const phone = message.from // número del usuario
const msgType = message.type // 'text' | 'image' | 'audio' | ...
// 3. Solo procesamos texto; para otros tipos enviamos instrucción
if (msgType !== 'text') {
await sendWhatsAppMessage(phone,
'Por favor escríbeme en texto para poder ayudarte 😊'
)
return new NextResponse('OK', { status: 200 })
}
const userText = message.text.body
// 4. Procesar mensaje con IA y responder (async, no bloqueamos a Meta)
processIncomingMessage({ phone, userText }).catch(console.error)
// Meta requiere 200 en menos de 5 s
return new NextResponse('OK', { status: 200 })
}
// Función helper para enviar mensajes
export async function sendWhatsAppMessage(to: string, text: string) {
const url = `https://graph.facebook.com/v20.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: text },
}),
})
}Importante: Meta espera una respuesta 200 en menos de 5 segundos. Por eso lanzamos processIncomingMessage sin await y respondemos inmediatamente. En producción, mover esto a una cola (BullMQ, Inngest) para mayor robustez.
Estado conversacional en PostgreSQL
Un bot que no recuerda el contexto es inútil. Guardamos el historial de cada conversación en PostgreSQL usando el número de teléfono como clave de sesión. El esquema es deliberadamente simple:
-- Tabla de sesiones conversacionales
CREATE TABLE bot_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone TEXT NOT NULL UNIQUE, -- +573001234567
history JSONB NOT NULL DEFAULT '[]',
-- [{ role: 'user'|'assistant', content: '...' }]
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_bot_sessions_phone ON bot_sessions(phone);
-- Trigger para actualizar updated_at automáticamente
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER bot_sessions_updated_at
BEFORE UPDATE ON bot_sessions
FOR EACH ROW EXECUTE FUNCTION update_updated_at();Limitamos el historial a los últimos 20 turnos (10 del usuario + 10 del bot) antes de enviarlo al LLM para controlar el costo de tokens. Una sesión sin actividad por más de 24 horas se reinicia automáticamente.
// lib/whatsapp/session.ts
import { db } from '@/lib/db' // instancia de postgres.js o Drizzle
const MAX_HISTORY = 20
const SESSION_TTL_HOURS = 24
export async function getOrCreateSession(phone: string) {
const row = await db.query.botSessions.findFirst({
where: (s, { eq }) => eq(s.phone, phone),
})
if (!row) {
return db.insert(botSessions).values({ phone, history: [] }).returning()
}
// Resetear sesión expirada
const hoursInactive = (Date.now() - row.updatedAt.getTime()) / 36e5
if (hoursInactive > SESSION_TTL_HOURS) {
await db.update(botSessions)
.set({ history: [] })
.where(eq(botSessions.phone, phone))
return { ...row, history: [] }
}
return row
}
export async function appendToHistory(
phone: string,
newTurns: { role: 'user' | 'assistant'; content: string }[]
) {
const session = await getOrCreateSession(phone)
const updated = [...session.history, ...newTurns].slice(-MAX_HISTORY)
await db.update(botSessions)
.set({ history: updated })
.where(eq(botSessions.phone, phone))
}Prompt engineering para el bot de citas
El system prompt es el 80% del éxito del bot. Debe codificar todo el conocimiento del negocio y las instrucciones de comportamiento. Para BotCitas lo estructuramos en bloques:
// lib/whatsapp/prompt.ts
export function buildSystemPrompt(today: Date): string {
const dia = today.toLocaleDateString('es-CO', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
})
return `Eres Sofia, asistente virtual de Clínica Estética Aurora en Bogotá.
Hoy es ${dia}. Responde siempre en español colombiano, de forma cálida y profesional.
Usa emojis con moderación.
## SERVICIOS Y PRECIOS
- Limpieza facial profunda: $85 000 COP — 60 min
- Tratamiento anti-acné: $120 000 COP — 75 min
- Hidratación intensiva: $95 000 COP — 60 min
- Diseño de cejas: $45 000 COP — 30 min
## HORARIOS
Lunes a viernes: 8:00 am – 7:00 pm
Sábado: 8:00 am – 3:00 pm
Domingos y festivos: CERRADO
## FLUJO DE AGENDAMIENTO
1. Pregunta qué servicio desea.
2. Pregunta la fecha y hora preferida. Interpreta expresiones como
"el próximo viernes en la tarde" o "mañana a las 3".
3. Confirma nombre completo del paciente.
4. Confirma el turno con todos los datos y un resumen.
5. Al confirmar, responde EXACTAMENTE en este formato JSON
(y nada más después de él):
BOOKING:{"servicio":"...","fecha":"YYYY-MM-DD","hora":"HH:mm","nombre":"..."}
## REGLAS
- Nunca inventes disponibilidad; di que un agente confirmará en breve.
- Si preguntan algo fuera de tu alcance, da el número directo: 601-234-5678.
- No discutas temas no relacionados con la clínica.`
}// lib/whatsapp/processor.ts
import { getOrCreateSession, appendToHistory } from './session'
import { buildSystemPrompt } from './prompt'
import { sendWhatsAppMessage } from '@/app/api/whatsapp/webhook/route'
export async function processIncomingMessage({
phone, userText,
}: { phone: string; userText: string }) {
const session = await getOrCreateSession(phone)
const newUserTurn = { role: 'user' as const, content: userText }
// Llamada a OpenRouter (compatible con OpenAI SDK)
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'HTTP-Referer': 'https://edwsystem.com',
},
body: JSON.stringify({
model: 'meta-llama/llama-3.1-70b-instruct',
messages: [
{ role: 'system', content: buildSystemPrompt(new Date()) },
...session.history,
newUserTurn,
],
max_tokens: 400,
temperature: 0.4,
}),
})
const data = await response.json()
const assistantText: string = data.choices[0].message.content
// Persistir historial
await appendToHistory(phone, [
newUserTurn,
{ role: 'assistant', content: assistantText },
])
// Detectar si el bot generó un booking
const bookingMatch = assistantText.match(/BOOKING:({.*})/)
if (bookingMatch) {
const booking = JSON.parse(bookingMatch[1])
await createAppointment(booking) // lógica de negocio propia
// Enviar solo el texto antes del JSON al usuario
const displayText = assistantText.replace(/BOOKING:{.*}/, '').trim()
await sendWhatsAppMessage(phone, displayText)
return
}
await sendWhatsAppMessage(phone, assistantText)
}Consideraciones para producción
Plantillas de mensajes salientes
WhatsApp solo permite enviar mensajes a usuarios que no te han escrito en las últimas 24 horas si usas una plantilla aprobada por Meta. Para recordatorios de cita, crea una plantilla en el Business Manager con variables dinámicas: {"type":"template","template":{"name":"recordatorio_cita","language":{"code":"es_CO"}}}.
Rate limits del tier gratuito
El tier gratuito incluye 1 000 conversaciones iniciadas por usuario al mes (cada conversación dura 24 h). A partir de la conversación 1 001 Meta cobra aproximadamente USD $0.012 por conversación en Colombia. Para la mayoría de negocios pequeños el tier gratuito es suficiente el primer año.
Manejo de medios (imágenes, audio)
El payload de Meta incluye un media_id que puedes usar para descargar el archivo con el mismo token. Para audio puedes transcribir con Whisper antes de pasarlo al LLM. En BotCitas optamos por simplemente pedirle al usuario que escriba en texto — el 95% de las interacciones son texto puro.
Los 5 días: qué construimos cada día
Infraestructura base
Crear la app de Facebook, registrar el webhook, verificar que Meta envíe mensajes al endpoint local usando ngrok. Esquema de base de datos y primeras pruebas de echo (el bot repite lo que le mandas).
Integración con el LLM
Conectar OpenRouter, construir el primer system prompt básico y verificar que el bot responde coherentemente. Implementar persistencia del historial en PostgreSQL.
Lógica de negocio
Refinar el system prompt con servicios, precios y horarios reales. Implementar el formato BOOKING y el parser del JSON. Conectar con el calendario del cliente (Google Calendar API).
Edge cases y calidad
Pruebas con usuarios reales internos. Manejar mensajes de voz, imágenes, stickers y ubicaciones. Agregar manejo de errores, reintentos en el envío de mensajes y logging con Sentry.
Deploy y monitoreo
Deploy en Vercel con variables de entorno de producción. Activar número de teléfono real con el cliente. Crear dashboard mínimo en el panel de administración para ver conversaciones activas.
Resultados después de 30 días
Al mes de operación, BotCitas tenía números concretos para mostrar:
La recepcionista pasó de contestar WhatsApp durante 3 horas diarias a revisar un resumen de 10 minutos al final del día. Los únicos casos que escala el bot son cuando el cliente quiere cambiar una cita ya confirmada — algo que requiere verificar disponibilidad en tiempo real — y consultas médicas específicas que están fuera del alcance del bot por diseño.
El costo mensual de operación fue de menos de USD $8: USD $0 en la API de WhatsApp (dentro del tier gratuito), ~USD $5 en tokens de LLM via OpenRouter, y el resto en base de datos. Para el cliente, el ROI fue positivo desde la primera semana.
¿Quieres construir algo similar?
BotCitas es un ejemplo de lo que es posible construir en poco tiempo cuando el stack es el correcto. Lo que hace difícil estos proyectos no es el código — es identificar el flujo conversacional preciso que resuelve el problema del negocio. Eso requiere trabajar de cerca con el cliente durante la primera semana.
Si tienes un proceso repetitivo que hoy consume el tiempo de tu equipo de atención al cliente, es probable que sea automatizable con un enfoque similar. Escríbenos y lo evaluamos juntos.