
La mayoría de tutoriales de bots de WhatsApp en Python acaban con tu número bloqueado.
Búscalo y mira lo que sale. Scripts de Selenium haciendo clic en WhatsApp Web. Librerías no oficiales que se hacen pasar por un móvil. Hilos de foros de 2020.
Sirven para una demo de fin de semana. Luego WhatsApp marca la cuenta y el número que conocen tus clientes desaparece.
Si estás:
Necesitas la API oficial.
En esta guía construimos un bot de WhatsApp en Python con una única tarea que vale dinero: cualifica los leads entrantes en cuatro mensajes o menos, los puntúa y pasa los calientes a una persona. API oficial, webhooks firmados, botones interactivos y Claude para la parte de lenguaje.
Código completo en GitHub. Probado de principio a fin.
Esto es lo que nadie te cuenta de la API oficial de WhatsApp.
El código es la parte fácil.
Antes de que tu bot envíe un solo mensaje, Meta espera que pases por esta lista:
Cada paso lleva minutos. Las aprobaciones, días.
No hay un plazo oficial para aprobar el nombre visible, y los rechazos rara vez explican qué corregir. Hasta que tu empresa esté verificada, te quedas con un límite diario de mensajes bajo.
Así que esperas. Tu prototipo se queda parado.
Tus leads no.
El segundo problema es más reciente.
Hasta el 30 de septiembre de 2026, las respuestas dentro de la ventana de atención al cliente de 24 horas de WhatsApp eran gratis. Desde el 1 de octubre, Meta también cobra los mensajes de servicio y las plantillas de utilidad dentro de esa ventana, con 1.000 mensajes de servicio gratis por número al mes (la guía de precios de Kapso lo desglosa).
Eso cambia cómo diseñas un chatbot de IA para WhatsApp.
El bot típico con LLM es charlatán. Saluda, pregunta en qué puede ayudar, hace una repregunta, parafrasea, vuelve a preguntar. Diez mensajes para saber lo que un formulario recogería en tres campos.
Encima, los leads entrantes se enfrían rápido. Un cliente potencial que escribe a las 11 de la noche y recibe respuesta a la tarde siguiente ya ha escrito a dos competidores.
Pagas por cada turno.
Y pierdes el lead igualmente.
La mayoría de equipos cree que un bot mejor es el que mantiene una conversación más larga.
Es justo al revés.
Para cualificar un lead solo necesitas tres respuestas: qué necesita, cuánto puede gastar y cuándo quiere empezar. El resto es ruido.
Así que la regla de diseño de este bot es sencilla:
El resultado: un mensaje para un lead bien informado, cuatro en el peor caso.
Kapso es infraestructura para desarrolladores sobre la WhatsApp Business Platform oficial de Meta. Es Meta Business Partner oficial, así que trabajas con la Cloud API real, no con un apaño.
Lo que importa para este tutorial:
Una advertencia honesta: Kapso no elimina las reglas de Meta. Las plantillas siguen pasando por la revisión de Meta, y escalar el volumen sigue exigiendo verificar tu empresa. Lo que elimina es la fontanería, y te deja construir mientras las aprobaciones avanzan en segundo plano.
Ventajas
Inconvenientes
Ideal para: desarrolladores que tienen WhatsApp como canal principal de su producto, sobre todo con agentes de IA.
¿Quieres seguir el tutorial? Crea una cuenta gratis en Kapso y consigue tu número sandbox antes del paso 1.
La arquitectura cabe en una línea: un cliente potencial escribe por WhatsApp, Kapso envía un webhook firmado a tu app de Flask, Claude extrae los datos del lead, tu código decide la siguiente pregunta y Kapso entrega la respuesta.
Necesitas Python 3.10+, Node.js 20.19+ (para la CLI), una cuenta de Kapso y, opcionalmente, una API key de Anthropic.
npm install -g @kapso/cli
kapso login
kapso setup
kapso login abre tu navegador. kapso setup resuelve tu proyecto y genera un enlace para conectar o dar de alta un número de WhatsApp. Empieza con el sandbox y cambia a un número dedicado cuando salgas a producción.
Luego copia el ID que vas a usar en todas partes:
kapso whatsapp numbers list --output json | jq '.[0].id'
Crea una API key del proyecto en el panel, en Integrations → API keys.
mkdir whatsapp-lead-bot && cd whatsapp-lead-bot
pip install flask requests python-dotenv
# .env
KAPSO_API_KEY=your_project_api_key
PHONE_NUMBER_ID=your_phone_number_id
KAPSO_WEBHOOK_SECRET=your_webhook_secret
ANTHROPIC_API_KEY=optional
Kapso expone el formato de la Cloud API de Meta detrás de su propio host. El mismo JSON que Meta, autenticado con X-API-Key. Necesitamos dos tipos de mensaje: texto plano y botones de respuesta.
# kapso_client.py
import os
import requests
BASE_URL = os.getenv("KAPSO_BASE_URL", "https://api.kapso.ai/meta/whatsapp/v24.0")
class KapsoClient:
def __init__(self, api_key: str, phone_number_id: str, base_url: str = BASE_URL):
self.url = f"{base_url}/{phone_number_id}/messages"
self.headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
def _post(self, payload: dict) -> dict:
r = requests.post(self.url, json=payload, headers=self.headers, timeout=10)
r.raise_for_status()
return r.json()
def send_text(self, to: str, body: str) -> dict:
return self._post({
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "text",
"text": {"preview_url": False, "body": body},
})
def send_buttons(self, to: str, body: str, buttons: list[tuple[str, str]]) -> dict:
# WhatsApp allows max 3 reply buttons, 20 characters per title
return self._post({
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "interactive",
"interactive": {
"type": "button",
"body": {"text": body},
"action": {"buttons": [
{"type": "reply", "reply": {"id": bid, "title": title[:20]}}
for bid, title in buttons[:3]
]},
},
})
Una comprobación rápida desde la terminal antes de escribir más código:
kapso whatsapp messages send --phone-number-id <id> --to "+34600111222" --text "Hello from Kapso"
Kapso firma cada entrega con HMAC-SHA256 en la cabecera X-Webhook-Signature, pone el nombre del evento en X-Webhook-Event y envía una X-Idempotency-Key que no cambia entre reintentos. Usa las tres:
whatsapp.message.received.# app.py
import hashlib, hmac, json, os
from dotenv import load_dotenv
from flask import Flask, abort, request
load_dotenv() # before importing modules that read env vars
import extractor, qualifier
from kapso_client import KapsoClient
app = Flask(__name__)
SECRET = os.environ["KAPSO_WEBHOOK_SECRET"]
LEADS_FILE = os.getenv("LEADS_FILE", "leads.jsonl")
kapso = KapsoClient(os.environ["KAPSO_API_KEY"], os.environ["PHONE_NUMBER_ID"])
sessions: dict[str, dict] = {} # use Redis in production
seen_deliveries: set[str] = set()
def valid_signature(raw: bytes, header: str) -> bool:
expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, (header or "").removeprefix("sha256="))
@app.post("/webhook")
def webhook():
raw = request.get_data()
if not valid_signature(raw, request.headers.get("X-Webhook-Signature")):
abort(401)
key = request.headers.get("X-Idempotency-Key")
if key and key in seen_deliveries:
return "", 200
if request.headers.get("X-Webhook-Event") != "whatsapp.message.received":
return "", 200
body = json.loads(raw)
events = body["data"] if body.get("batch") else [body] # message buffering
for event in events:
handle_message(event)
if key:
seen_deliveries.add(key) # only after success, so a failed delivery can be retried
return "", 200
El payload conserva la forma de mensaje de Meta más un bloque kapso. Un mensaje de texto llega como message.text.body; un toque en un botón, como message.interactive.button_reply. No des por hecho que el número de teléfono siempre viene: Meta está desplegando IDs de usuario ligados a cada empresa, así que léelo de conversation.phone_number con un valor de respaldo.
El LLM tiene un único trabajo, muy acotado: convertir texto libre en tres campos. Prompt pequeño, modelo pequeño, JSON estricto. Si la API cae, el bot recurre a reglas por palabras clave en lugar de quedarse callado.
# extractor.py
import json, os, re
import requests
SYSTEM_PROMPT = """You extract B2B lead data from a WhatsApp message.
Return ONLY a JSON object with these keys (null when not mentioned):
- "need": short summary of what the prospect wants
- "budget": "low" (<1k USD), "mid" (1k-5k) or "high" (>5k)
- "timeline": "now" (this month), "soon" (1-3 months) or "exploring"
No markdown, no extra text."""
VALID = {"budget": {"low", "mid", "high"}, "timeline": {"now", "soon", "exploring"}}
GREETINGS = {"hi", "hello", "hey", "hola", "buenas", "good morning", "good afternoon"}
def extract(text: str) -> dict:
"""Claude first; keyword rules if there's no API key or the API fails."""
if not os.getenv("ANTHROPIC_API_KEY"):
return extract_rules(text)
try:
return _clean(extract_llm(text))
except (requests.RequestException, ValueError, KeyError):
return extract_rules(text)
def extract_llm(text: str) -> dict:
r = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": os.getenv("LLM_MODEL", "claude-haiku-5-5"),
"max_tokens": 200,
"system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": text}],
},
timeout=15,
)
r.raise_for_status()
raw = "".join(b.get("text", "") for b in r.json()["content"])
raw = raw.replace("```json", "").replace("```", "").strip()
data = json.loads(raw)
return {k: data.get(k) for k in ("need", "budget", "timeline")}
def _clean(data: dict) -> dict:
# an off-enum value would count as "answered" and the bot would never ask again
for field, allowed in VALID.items():
if data.get(field) not in allowed:
data[field] = None
return data
def extract_rules(text: str) -> dict:
t = text.lower()
return {"need": _need(text), "budget": _budget(t), "timeline": _timeline(t)}
def _need(text: str) -> str | None:
words = re.findall(r"\w+", text.lower())
if len(words) < 4 or " ".join(words) in GREETINGS:
return None
return text.strip()[:120]
def _budget(t: str) -> str | None:
m = (re.search(r"\$\s?(\d+(?:[.,]\d+)?)\s?(k)?\b", t)
or re.search(r"(\d+(?:[.,]\d+)?)\s?(k)\b", t)
or re.search(r"(\d+(?:[.,]\d+)?)\s?()(?:usd|dollars|eur|euros)\b", t))
if not m:
return None
amount = float(m.group(1).replace(",", ".")) * (1000 if m.group(2) else 1)
return "low" if amount < 1000 else "mid" if amount <= 5000 else "high"
def _timeline(t: str) -> str | None:
if re.search(r"\b(this month|this week|asap|right away|urgent|immediately)\b", t):
return "now"
if re.search(r"\b(next month|few months|\d\s?-\s?\d months|this quarter|next quarter)\b", t):
return "soon"
if re.search(r"\b(exploring|no rush|just looking|someday|researching)\b", t):
return "exploring"
return None
Aquí vive la regla de "hablar menos". El bot solo pregunta lo que falta, y hace las preguntas cerradas con botones.
# qualifier.py
BUDGET_BUTTONS = [("budget_low", "Under $1k"), ("budget_mid", "$1k - $5k"), ("budget_high", "Over $5k")]
TIMELINE_BUTTONS = [("timeline_now", "This month"), ("timeline_soon", "1-3 months"), ("timeline_exploring", "Just exploring")]
SCORES = {"budget": {"high": 40, "mid": 25, "low": 5},
"timeline": {"now": 40, "soon": 25, "exploring": 5}}
def score(lead: dict) -> tuple[int, str]:
points = SCORES["budget"].get(lead.get("budget"), 0) + SCORES["timeline"].get(lead.get("timeline"), 0)
points += 20 if lead.get("need") else 0
return points, "hot" if points >= 80 else "warm" if points >= 40 else "cold"
def next_action(lead: dict) -> dict:
if not lead.get("need"):
return {"type": "text", "body": f"Hi {lead.get('name') or 'there'}! What are you looking to build or solve?"}
if not lead.get("budget"):
return {"type": "buttons", "body": "What budget range do you have in mind?", "buttons": BUDGET_BUTTONS}
if not lead.get("timeline"):
return {"type": "buttons", "body": "When would you like to get started?", "buttons": TIMELINE_BUTTONS}
points, tier = score(lead)
lead.update(score=points, tier=tier, qualified=True)
return {"type": "done", "tier": tier, "body": {
"hot": "Perfect, you're a great fit. A specialist will message you here in a few minutes.",
"warm": "Thanks! A teammate will follow up this week with relevant case studies.",
"cold": "Thanks for the context! Here are some free resources to explore at your own pace.",
}[tier]}
Y el handler que lo une todo:
# app.py (continued)
def handle_message(event: dict) -> None:
message, conversation = event["message"], event["conversation"]
if message.get("kapso", {}).get("direction") != "inbound":
return
to = conversation.get("phone_number") or message.get("from")
lead = sessions.setdefault(conversation["id"], {"name": conversation.get("contact_name"), "phone": to})
if lead.get("qualified"):
return # a human owns the conversation now
if message["type"] == "interactive":
field, _, value = message["interactive"]["button_reply"]["id"].partition("_")
lead[field] = value
elif message["type"] == "text":
for k, v in extractor.extract(message["text"]["body"]).items():
lead[k] = lead.get(k) or v
action = qualifier.next_action(lead)
if action["type"] == "buttons":
kapso.send_buttons(to, action["body"], action["buttons"])
else:
kapso.send_text(to, action["body"])
if action["type"] == "done":
save_lead(lead)
def save_lead(lead: dict) -> None:
# swap for your CRM, Slack or Google Sheets
record = {k: v for k, v in lead.items() if k != "qualified"}
with open(LEADS_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
if __name__ == "__main__":
app.run(port=int(os.getenv("PORT", 8000)))
El bot deja de responder en cuanto un lead está cualificado. Es a propósito: la bandeja de entrada de Kapso trae el traspaso a una persona integrado, así que tu equipo de ventas retoma el mismo hilo con todo el historial.
Arranca la app y exponla:
python app.py # listens on :8000
ngrok http 8000 # gives you a public HTTPS URL
Después apunta Kapso hacia ella:
kapso whatsapp webhooks new \
--phone-number-id <id> \
--url "https://<your-subdomain>.ngrok.app/webhook" \
--event whatsapp.message.received \
--active
Copia el secreto de firma del webhook en KAPSO_WEBHOOK_SECRET, reinicia la app y envía "hi" a tu número sandbox.
Si tus clientes suelen mandar varios mensajes cortos seguidos, añade --buffer-enabled --buffer-window-seconds 5. Kapso los agrupará en una sola entrega, y el handler de arriba ya recorre los lotes.
El repo incluye un mock del endpoint de mensajes de Kapso y un demo.py que envía webhooks firmados con la forma exacta del payload de la documentación de Kapso. Además, 13 tests unitarios: firmas falsificadas, entregas reintentadas y fallidas, payloads en lote, ecos de mensajes salientes, el respaldo cuando falla el LLM, valores del LLM fuera del enum, la puntuación y el límite de 20 caracteres de los botones.
python demo.py
python -m unittest -v


Esta es la salida recortada de demo.py. El bot responde en inglés porque los textos del repo están en inglés; cambiarlos es editar tres cadenas en qualifier.py.
Esta ejecución no usó API key de Anthropic, así que la extracción la hizo el respaldo por palabras clave. Con Claude, need vuelve como un resumen corto en lugar del mensaje original.

Lead 1: todo de entrada (1 mensaje del bot)
👤 Laura: Hi! We need a WhatsApp bot to book appointments for our 3 dental
clinics. Budget around $8k, we want it live this month.
🤖 Bot: Perfect, you're a great fit. A specialist will message you here
in a few minutes.
→ HOT (100)
El extractor sacó la necesidad, el presupuesto y el plazo de un solo mensaje. Ninguna pregunta.
Lead 2: los botones hacen el trabajo (4 mensajes del bot)
👤 Marcos: hello
🤖 Bot: Hi Marcos! What are you looking to build or solve?
👤 Marcos: We want to automate support replies for our online store
🤖 Bot: What budget range do you have in mind?
[Under $1k] [$1k - $5k] [Over $5k]
👤 Marcos: (taps) $1k - $5k
🤖 Bot: When would you like to get started?
[This month] [1-3 months] [Just exploring]
👤 Marcos: (taps) 1-3 months
🤖 Bot: Thanks! A teammate will follow up this week with relevant case studies.
→ WARM (70)
Lead 3: poca intención, filtrado rápido (2 mensajes del bot)
👤 Ana: Just exploring chatbot options for a side project, no rush
🤖 Bot: What budget range do you have in mind?
[Under $1k] [$1k - $5k] [Over $5k]
👤 Ana: (taps) Under $1k
🤖 Bot: Thanks for the context! Here are some free resources to explore at your own pace.
→ COLD (30)
El plazo salió del "no rush" de su primer mensaje, así que el bot se saltó esa pregunta.
Cada lead cualificado acaba en leads.jsonl:
{"name": "Laura", "phone": "+34600111001", "need": "Hi! We need a WhatsApp bot to book appointments for our 3 dental clinics. Budget around $8k, we want it live this month.", "budget": "high", "timeline": "now", "score": 100, "tier": "hot"}
Cambia ese archivo por tu CRM y tu equipo de ventas solo hablará con quien merece la pena.
❓ ¿Cómo creo un bot de WhatsApp en Python? ✅ Usa la API oficial de WhatsApp Cloud a través de un proveedor como Kapso. Tu app de Python recibe un webhook por cada mensaje entrante y responde con un POST al endpoint de mensajes. Con Flask y requests basta.
❓ ¿Necesito la verificación de empresa de Meta para usar la API de WhatsApp? ✅ No para empezar a construir. El sandbox de Kapso te deja enviar mensajes reales sin verificación de Meta. Para volumen de producción, plantillas aprobadas y un nombre visible revisado, Meta sigue exigiendo una empresa verificada.
❓ ¿Puedo crear un chatbot de IA para WhatsApp gratis? ✅ Puedes construirlo y probarlo gratis con el plan gratuito y el número sandbox de Kapso. En producción pagas las tarifas por mensaje de Meta más el uso del LLM, que este diseño mantiene bajo al limitar cada conversación a cuatro mensajes del bot como máximo.
❓ ¿Es seguro automatizar WhatsApp con Selenium o librerías no oficiales? ✅ No. Automatizar WhatsApp Web incumple los términos de WhatsApp y te arriesgas a que bloqueen el número. Usa la API oficial para cualquier cosa de cara al cliente.
❓ ¿Cómo verifico un webhook de WhatsApp de Kapso? ✅ Calcula un HMAC-SHA256 del cuerpo bruto de la petición con tu secreto del webhook y compáralo con la cabecera X-Webhook-Signature usando una comparación de tiempo constante. Si no coinciden, rechaza la petición con un 401.
Escribir un bot de WhatsApp en Python no es difícil. Lo difícil era ponerlo en la API oficial sin perder una semana con la configuración de Meta.
Kapso resuelve esa parte. Tu trabajo es que cada mensaje cuente.
Crea el tuyo hoy en el sandbox de Kapso: es gratis, y tu próximo lead probablemente ya está escribiendo.
¿Buscas contenido técnico para tu empresa? Te puedo ayudar — LinkedIn · kevinmenesesgonzalez@gmail.com