Bot de WhatsApp con IA

Un bot de WhatsApp con IA es un servidor que recibe mensajes de la Cloud API de Meta por webhook, consulta un modelo de lenguaje y responde. En un bot que solo responde, el mayor costo suele ser el modelo.

Actualizado el 7 min de lectura

Objetivos de aprendizaje

Después de leer este artículo podrás:

  • Entender el camino de un mensaje de WhatsApp hasta el LLM y de vuelta
  • Escribir un webhook en FastAPI que verifica la firma y responde con IA
  • Estimar lo que cobran Meta, el modelo y el hosting
En esta página

Un bot de WhatsApp con IA parece complicado, pero es una API web común que habla con dos partes: Meta, que entrega y envía los mensajes, y un proveedor de modelos de lenguaje, que escribe la respuesta. Tu código queda en el medio y decide qué sabe el modelo y cuánto gastas.

La decisión más importante llega antes del código: usar la Cloud API oficial de Meta o una librería no oficial. Aquí usamos la Cloud API, porque es la vía soportada y funciona por webhook, sin mantener abierta una sesión de WhatsApp Web.

UsuarioWhatsAppCloud APIoficialmensajerespuestaPOSTPOSTTu servidorwebhook HTTPSverifica la firmaresponde 200 OKignora repetidosAPI del LLMescribe larespuestahistorialtexto
El webhook verifica la firma y responde 200 al instante; el LLM corre en segundo plano y la respuesta vuelve por la Cloud API.

¿Cómo es la arquitectura de un bot de WhatsApp con IA?#

Son cuatro piezas y un camino de ida y vuelta:

  1. El usuario envía un mensaje al número del bot.
  2. La Cloud API de Meta hace un POST a tu webhook, un endpoint HTTPS público.
  3. Tu servidor valida la firma, devuelve 200 OK y, en segundo plano, envía el historial de la conversación al LLM. Para entender qué hace el modelo por dentro, mira qué es un LLM.
  4. La respuesta vuelve con un POST a la Cloud API, que la entrega al usuario.

Esto tiene dos consecuencias prácticas. El servidor tiene que estar siempre encendido, porque Meta solo reenvía la notificación durante un tiempo limitado. Y el webhook necesita una URL HTTPS pública, así que probar en localhost requiere un túnel.

¿Qué necesitas antes de empezar?#

  • Una cuenta de desarrollador de Meta con una app y el producto WhatsApp agregado. En el panel obtienes el Phone Number ID, el token de acceso y el App Secret de la app.
  • Una clave de API de un proveedor de LLM. El ejemplo usa la de Anthropic.
  • Una versión de Python que todavía tenga soporte y nociones de variables de entorno.

Para el paso a paso de la cuenta y del número, sin IA, mira cómo hacer un bot de WhatsApp.

¿Cómo escribir el webhook?#

El requirements.txt:

text
fastapi
uvicorn
httpx
anthropic

Y el main.py. Hace cuatro cosas: responde al desafío de verificación (GET), comprueba la firma X-Hub-Signature-256 (POST), ignora mensajes repetidos y responde con el modelo en segundo plano.

python
import hashlib
import hmac
import os
from collections import OrderedDict, defaultdict, deque

import httpx
from anthropic import AsyncAnthropic
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse

VERIFY_TOKEN = os.environ["VERIFY_TOKEN"]  # cadena que eliges en el panel de Meta
APP_SECRET = os.environ["APP_SECRET"].encode()
WA_TOKEN = os.environ["WA_TOKEN"]
PHONE_ID = os.environ["WA_PHONE_NUMBER_ID"]
GRAPH_API_VERSION = os.environ["GRAPH_API_VERSION"]  # en formato vXX.0, del changelog de Meta
MODEL = os.environ["ANTHROPIC_MODEL"]  # ID de la lista de modelos de Anthropic
SYSTEM = (
    "Eres el asistente de atención de la tienda Ejemplo en WhatsApp. "
    "Responde en español, en máximo 3 frases, sin markdown."
)

SEND_URL = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{PHONE_ID}/messages"
llm = AsyncAnthropic()  # lee ANTHROPIC_API_KEY del entorno
app = FastAPI()

history: dict[str, deque] = defaultdict(lambda: deque(maxlen=10))  # en RAM: se pierde al reiniciar
seen: OrderedDict[str, None] = OrderedDict()


@app.get("/webhook")
async def verify(
    mode: str = Query("", alias="hub.mode"),
    token: str = Query("", alias="hub.verify_token"),
    challenge: str = Query("", alias="hub.challenge"),
):
    if mode == "subscribe" and hmac.compare_digest(token.encode(), VERIFY_TOKEN.encode()):
        return PlainTextResponse(challenge)
    raise HTTPException(403)


@app.post("/webhook")
async def receive(request: Request, tasks: BackgroundTasks):
    raw = await request.body()  # la firma es sobre el cuerpo crudo, no sobre el JSON reparseado
    expected = "sha256=" + hmac.new(APP_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected.encode(), received.encode()):
        raise HTTPException(403)

    for entry in (await request.json()).get("entry", []):
        for change in entry.get("changes", []):
            for msg in change.get("value", {}).get("messages", []):
                if msg["id"] in seen or msg.get("type") != "text":
                    continue
                seen[msg["id"]] = None
                if len(seen) > 1000:
                    seen.popitem(last=False)
                tasks.add_task(answer, msg["from"], msg["text"]["body"])
    return {"ok": True}  # 200 inmediato; Meta reenvía si no lo recibe


async def answer(user: str, text: str):
    turns = history[user]
    turns.append({"role": "user", "content": text})
    while turns and turns[0]["role"] != "user":
        turns.popleft()  # el historial debe empezar por "user"
    try:
        out = await llm.messages.create(
            model=MODEL,
            max_tokens=1024,
            output_config={"effort": "low"},
            system=SYSTEM,
            messages=list(turns),
        )
        reply = "".join(b.text for b in out.content if b.type == "text") or "..."  # ignora bloques de thinking
    except Exception as e:
        print("llm error:", e)
        reply = "No pude responder ahora. Inténtalo de nuevo en unos instantes."
    turns.append({"role": "assistant", "content": reply})

    async with httpx.AsyncClient(timeout=15) as http:
        r = await http.post(
            SEND_URL,
            headers={"Authorization": f"Bearer {WA_TOKEN}"},
            json={
                "messaging_product": "whatsapp",
                "to": user,
                "type": "text",
                "text": {"body": reply},
            },
        )
        if r.status_code >= 400:
            print("whatsapp error:", r.status_code, r.text)

Qué observar en el código:

  • Responde 200 antes de llamar al modelo. Si el endpoint no devuelve 200, Meta reintenta con frecuencia decreciente durante hasta 7 días. Sin seen, el cliente recibiría respuestas duplicadas.
  • Valida la firma. La documentación de Meta dice que la validación no es obligatoria, pero sí recomendada. Sin ella, cualquiera que descubra la URL puede enviar mensajes falsos y gastar tu crédito de IA.
  • from es el número del usuario y sirve directamente como destino (to) de la respuesta.
  • El modelo y la versión de la Graph API son configuración. Anthropic retira modelos, y cada versión de la Graph API deja de funcionar unos dos años después de su lanzamiento. Por eso los dos quedan en variables de entorno, sin valor por defecto: si falta una, la app se detiene al iniciar con KeyError, y actualizar pasa a ser cambiar el valor y reiniciar. Copia el ID de un modelo pequeño, rápido y barato (de la línea Haiku, por ejemplo) de la lista de modelos de Anthropic y la versión más reciente del changelog de la Graph API.
  • El razonamiento entra en max_tokens. Si el modelo elegido piensa antes de responder, esos tokens cuentan en el límite y se cobran como salida. Con un max_tokens demasiado bajo, la respuesta puede salir cortada o vacía. El effort bajo reduce el razonamiento en una atención simple, y 1024 deja margen para la respuesta corta. Si el modelo no acepta el parámetro effort, la API devuelve un error 400: quita esa línea.
  • El código solo trata texto. Audio, imagen y botones llegan con otro type; agrega una rama para cada uno que tu bot soporte.

¿Cómo configurar y probar el webhook?#

Ejecútalo en local y expón el puerto con un túnel HTTPS:

bash
pip install -r requirements.txt
export VERIFY_TOKEN="una-cadena-secreta" APP_SECRET="..." WA_TOKEN="..." \
       WA_PHONE_NUMBER_ID="..." ANTHROPIC_API_KEY="..." \
       ANTHROPIC_MODEL="id-de-un-modelo-pequeno" GRAPH_API_VERSION="vXX.0"
uvicorn main:app --port 8000

En el panel de Meta, en la configuración de WhatsApp, indica la URL https://tu-dominio/webhook y el mismo VERIFY_TOKEN, y suscríbete al campo messages. Para ver la verificación funcionando antes:

bash
curl "localhost:8000/webhook?hub.mode=subscribe&hub.verify_token=una-cadena-secreta&hub.challenge=123"
echo "(la respuesta debe ser 123)"

Después envía un mensaje desde tu celular al número del bot y sigue los logs del servidor.

¿Cuánto cuesta un bot de WhatsApp con IA?#

Son tres cuentas separadas:

  • Meta. El cobro es por plantilla entregada, y no por conversación. Los mensajes que no son plantilla (texto, imagen) dentro de la ventana de atención de 24 horas son gratuitos. Un bot que solo responde al cliente queda, en general, dentro de esa ventana. Los envíos activos, como avisos y promociones, usan plantillas y se cobran por categoría y país. Mira los detalles en costo de la API oficial de WhatsApp.
  • Modelo. Pagas por token de entrada y de salida. Como el historial se reenvía en cada mensaje, las conversaciones largas cuestan más; el maxlen=10 y el max_tokens=1024 del código lo contienen. Para hacer las cuentas, mira cuánto cuesta una API de LLM.
  • Hosting. Un webhook que solo reenvía texto usa poca memoria. Lo que pesa es estar siempre encendido.

¿Cuáles son los límites y errores comunes?#

  • La memoria se pierde al reiniciar. El history vive en la RAM. En producción, guarda los últimos mensajes en Redis o en una base de datos, indexados por el número.
  • Ventana de 24 horas. Fuera de ella, solo pasan plantillas aprobadas. Si el bot tiene que iniciar la conversación, planifica plantillas.
  • Falla al guardar el webhook en el panel: el VERIFY_TOKEN del panel es distinto al de la variable de entorno, o el servidor no es accesible por HTTPS público.
  • Firma siempre inválida: el APP_SECRET es incorrecto o validaste sobre el JSON reserializado en vez de request.body().
  • Respuestas duplicadas: el servidor tardó o falló al devolver 200. Confirma que el trabajo del modelo corre en segundo plano.
  • Abuso y prompt injection: el usuario controla el texto que lee el modelo. No le des herramientas ni datos sensibles sin filtro y, con mucho volumen, limita los mensajes por número.

Cómo hacerlo en Square Cloud#

El webhook necesita dos cosas que la computadora de casa no da: estar en línea siempre que Meta llame y una URL HTTPS pública. En Square Cloud, la aplicación corre en un contenedor aislado y siempre encendido, sin cold start ni modo de suspensión, y recibe un subdominio *.squareweb.app con HTTPS que puede servir como URL del webhook en el panel de Meta, sin túnel.

  1. Sube main.py y requirements.txt como ZIP en el panel, o usa la CLI, GitHub o la extensión de VS Code. El archivo squarecloud.app es opcional: el panel lo genera en el deploy, o lo escribes en la raíz del proyecto.
  2. Usa uvicorn main:app --host 0.0.0.0 --port $PORT como comando de inicio: una aplicación web escucha en 0.0.0.0, en el puerto de la variable PORT, y usa al menos 512 MB del pool de RAM del plan.
  3. Registra VERIFY_TOKEN, APP_SECRET, WA_TOKEN, WA_PHONE_NUMBER_ID, GRAPH_API_VERSION, ANTHROPIC_API_KEY y ANTHROPIC_MODEL en las variables de entorno de la aplicación. El cambio se aplica en el siguiente reinicio, sin rebuild.
  4. Envía el primer mensaje con los logs en tiempo real abiertos: los errores de Meta y del modelo aparecen ahí.

Desde el plan Standard (R$44,99 al mes), el hosting de Redis puede guardar el historial entre reinicios y, en el mismo plan, el AI Gateway puede reemplazar al proveedor del modelo: una API compatible con la de OpenAI para el modelo cubic, en beta con acceso anticipado sin costo extra. Solo acepta texto, que es lo único que trata este webhook. Agrega openai al requirements.txt, cambia la llamada dentro de answer y borra la línea de MODEL, que deja de usarse:

python
from openai import AsyncOpenAI

llm = AsyncOpenAI(
    base_url="https://api.squarecloud.app/v2/ai",
    api_key=os.environ["SQUARECLOUD_API_KEY"],  # clave de la cuenta con el scope ai:chat
)

# dentro de answer():
out = await llm.chat.completions.create(
    model="cubic",
    messages=[{"role": "system", "content": SYSTEM}, *turns],
)
reply = out.choices[0].message.content or "..."

Revisa los límites antes de cambiar: en el Standard, hasta 2,5 millones de tokens y hasta 1000 solicitudes por día, una solicitud a la vez. Con muchos clientes escribiendo al mismo tiempo, una llamada que llega mientras otra está en curso recibe un error 429; el SDK de OpenAI reintenta algunas veces, y el except del código responde con el mensaje de reserva. Los límites y los planes pueden cambiar cuando termine la beta. El Hobby, desde R$12,49 al mes, aloja el webhook, pero no incluye bases de datos ni el gateway, y Meta cobra las plantillas aparte. Mira los detalles en bots de WhatsApp.

IA

Aloja apps con IA en contenedores siempre encendidos

Más información

Preguntas frecuentes

¿Puedo usar una librería como Baileys en vez de la Cloud API?

Se puede, pero librerías como Baileys no son la vía oficial de Meta y dependen de una sesión abierta de WhatsApp Web. La Cloud API es la opción soportada y funciona por webhook, sin sesión que mantener.

¿Por qué el bot responde dos veces al mismo mensaje?

Meta reenvía la notificación si tu servidor no devuelve 200. Responde 200 enseguida, haz el trabajo en segundo plano y guarda el id de los mensajes ya tratados.

¿El bot puede responder a cualquier hora?

Con mensaje libre, solo mientras la ventana de atención de 24 horas esté abierta, y esa ventana se reinicia con cada mensaje del cliente. Fuera de ella, solo plantillas aprobadas por Meta.

¿Meta cobra cada respuesta del bot?

No. Los mensajes que no son plantillas, enviados dentro de la ventana de atención abierta, son gratuitos. Meta cobra por plantilla entregada, según la categoría y el país.

Siguiente artículoQué es una base de datos vectorialUna base de datos vectorial guarda embeddings (listas de números) y busca lo más parecido en significado, no solo en palabras. Cómo funciona y cuándo usarla.