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
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.
¿Cómo es la arquitectura de un bot de WhatsApp con IA?#
Son cuatro piezas y un camino de ida y vuelta:
- El usuario envía un mensaje al número del bot.
- La Cloud API de Meta hace un
POSTa tu webhook, un endpoint HTTPS público. - Tu servidor valida la firma, devuelve
200 OKy, 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. - La respuesta vuelve con un
POSTa 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 elApp Secretde 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:
fastapi
uvicorn
httpx
anthropicY 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.
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. Sinseen, 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.
fromes 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 unmax_tokensdemasiado bajo, la respuesta puede salir cortada o vacía. Eleffortbajo reduce el razonamiento en una atención simple, y 1024 deja margen para la respuesta corta. Si el modelo no acepta el parámetroeffort, 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:
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 8000En 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:
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=10y elmax_tokens=1024del 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
historyvive 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_TOKENdel 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_SECRETes incorrecto o validaste sobre el JSON reserializado en vez derequest.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.
- Sube
main.pyyrequirements.txtcomo ZIP en el panel, o usa la CLI, GitHub o la extensión de VS Code. El archivosquarecloud.appes opcional: el panel lo genera en el deploy, o lo escribes en la raíz del proyecto. - Usa
uvicorn main:app --host 0.0.0.0 --port $PORTcomo comando de inicio: una aplicación web escucha en0.0.0.0, en el puerto de la variablePORT, y usa al menos 512 MB del pool de RAM del plan. - Registra
VERIFY_TOKEN,APP_SECRET,WA_TOKEN,WA_PHONE_NUMBER_ID,GRAPH_API_VERSION,ANTHROPIC_API_KEYyANTHROPIC_MODELen las variables de entorno de la aplicación. El cambio se aplica en el siguiente reinicio, sin rebuild. - 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:
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
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.
Más en IA
Ver los 13 artículos- ¿Cuánto cuesta crear y mantener un chatbot con IA?Mantener un chatbot con IA cuesta tokens del modelo, hosting, base de datos y, con RAG, contexto extra. Mira cómo hacer la cuenta por conversación.
- ¿Cuánto cuesta un sistema RAG? Embeddings, base de datos y LLMUn RAG tiene tres costos: embeddings, base de datos vectorial y LLM. Mira la cuenta para mil preguntas al día, con precios hipotéticos, y dónde crece la factura.
- Cómo crear un bot de Discord con IACrea un bot de Discord con IA en discord.js: comando /ask, clave de API en variable de entorno, memoria por canal, límites de Discord y cómo ponerlo en línea.
- Cómo alojar un chatbot con IAUn chatbot con IA es una API que recibe mensajes, llama a un modelo de lenguaje y devuelve la respuesta en streaming. Mira el código y cómo ponerlo en línea.