← Blog

·

Bot de WhatsApp con Python en la API oficial (FastAPI, desde cero)

Actualizado el 28/10/2026

Para crear un bot de WhatsApp con Python en la API oficial necesitas un servidor HTTPS con dos rutas: un GET que responde al desafío de verificación de Meta y un POST que recibe los mensajes, valida la firma X-Hub-Signature-256 y contesta llamando al endpoint /messages de la Cloud API. Con FastAPI y httpx todo cabe en un archivo de unas 150 líneas, y eso es lo que este tutorial arma desde cero.

El bot final responde a «hola» o «menu» con tres botones, procesa el toque en cada botón, marca el mensaje como leído, ignora eventos repetidos y corre en producción con Docker y HTTPS automático. El código se probó con FastAPI 0.128 y funciona tanto directo con Meta como a través de Vectalk, cambiando solo dos variables de entorno.

Cómo funciona el bot

El flujo tiene cuatro pasos:

  1. El cliente envía un mensaje a tu número.
  2. Meta envía un POST con el evento a la URL de tu webhook.
  3. Tu servidor valida la firma, responde 200 de inmediato y procesa el evento en segundo plano.
  4. El bot llama a POST /<PHONE_NUMBER_ID>/messages para responder.

Como la respuesta sale justo después del mensaje del cliente, está dentro de la ventana de atención de 24 horas y puede ser texto libre o un mensaje interactivo, sin plantilla. Si todavía estás decidiendo cómo acceder a la API, la guía de WhatsApp Cloud API explica las opciones.

Qué necesitas antes

VariableQué esDónde encontrarla
PHONE_NUMBER_IDID del número en la Cloud APIPanel de apps, sección de WhatsApp
ACCESS_TOKENToken con el permiso whatsapp_business_messagingToken de usuario del sistema
APP_SECRETClave secreta de la app, usada en la firmaConfiguración de la app > Básica
VERIFY_TOKENCualquier texto que elijasLo defines tú y lo repites en el panel

Usa un token permanente de usuario del sistema: el token temporal del panel vence y tumba el bot. El número también debe estar registrado en la Cloud API; si todavía no lo está, mira cómo registrar el número.

En tu máquina necesitas Python 3.10 o superior.

¿Quieres probar mientras lees? Crea tu cuenta en Vectalk y envía el primer mensaje por la API oficial. Crear cuenta gratis →

1. Prepara el proyecto

mkdir bot-whatsapp && cd bot-whatsapp
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" httpx

El paquete fastapi[standard] instala Uvicorn y el comando fastapi. httpx hace las llamadas asíncronas a la API.

Crea el archivo main.py. Los bloques de código de las secciones 1 a 5 forman el archivo completo: pégalos en orden.

import hashlib
import hmac
import json
import logging
import os

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

VERIFY_TOKEN = os.environ["VERIFY_TOKEN"]
ACCESS_TOKEN = os.environ["ACCESS_TOKEN"]
APP_SECRET = os.environ.get("APP_SECRET", "")
WEBHOOK_KEY = os.environ.get("WEBHOOK_KEY", "")
PHONE_NUMBER_ID = os.environ.get("PHONE_NUMBER_ID", "")
API_URL = os.environ.get(
    "API_URL", f"https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/messages"
)

log = logging.getLogger("bot")
app = FastAPI()
client = httpx.AsyncClient(timeout=10)
processed = set()  # en producción, usa Redis o tu base de datos

API_URL usa por defecto la URL de Meta. Para usar Vectalk, solo cambias esta variable y el token, como se muestra más abajo.

2. Verificación del webhook (GET)

Cuando guardas la URL de callback en el panel, Meta envía un GET con hub.mode=subscribe, hub.verify_token y hub.challenge. Si el token coincide con el tuyo, responde 200 con el valor de hub.challenge en texto plano. Cualquier otra respuesta deja el endpoint sin verificar y Meta no envía eventos.

@app.get("/webhook")
def verify(request: Request):
    q = request.query_params
    if q.get("hub.mode") == "subscribe" and q.get("hub.verify_token") == VERIFY_TOKEN:
        return PlainTextResponse(q.get("hub.challenge", ""))
    raise HTTPException(status_code=403)

Los nombres de los parámetros llevan punto (hub.mode), por eso el código lee request.query_params en lugar de declarar parámetros en la función.

3. Validar la firma X-Hub-Signature-256

Cada POST de Meta trae el encabezado X-Hub-Signature-256: sha256=<hash>. El hash es un HMAC-SHA256 del cuerpo de la solicitud, calculado con la clave secreta de la app. Sin esta validación, cualquiera que descubra tu URL puede falsificar mensajes de clientes.

def signature_ok(body: bytes, header: str) -> bool:
    if not APP_SECRET:
        return True
    expected = "sha256=" + hmac.new(APP_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

Dos cuidados que ahorran horas de depuración:

  • Usa los bytes originales. Calcula el HMAC sobre await request.body(), nunca sobre un JSON serializado de nuevo. Un espacio distinto cambia el hash.
  • Compara en tiempo constante. hmac.compare_digest evita que el tiempo de comparación revele el hash.

El artículo sobre cómo validar la firma del webhook profundiza en el tema y muestra los errores más comunes.

4. Recibir el evento y responder rápido

Meta espera un 200 rápido. Si la entrega falla, reintenta con frecuencia decreciente durante hasta 7 días, así que el mismo evento puede llegar más de una vez. Por eso el código responde antes de procesar e ignora los ID de mensaje ya vistos.

@app.post("/webhook")
async def receive(request: Request, background: BackgroundTasks):
    if WEBHOOK_KEY and request.query_params.get("key") != WEBHOOK_KEY:
        raise HTTPException(status_code=403)
    body = await request.body()
    if not signature_ok(body, request.headers.get("x-hub-signature-256", "")):
        raise HTTPException(status_code=401, detail="firma invalida")
    background.add_task(process, json.loads(body))
    return {"ok": True}


async def process(payload: dict) -> None:
    for entry in payload.get("entry", []):
        for change in entry.get("changes", []):
            for msg in change.get("value", {}).get("messages", []):
                if msg["id"] in processed:
                    continue  # Meta puede reenviar el mismo evento
                processed.add(msg["id"])
                try:
                    await handle(msg)
                except httpx.HTTPError:
                    log.exception("error al responder %s", msg["id"])

BackgroundTasks de FastAPI ejecuta process después de enviar la respuesta. Los eventos de estado (sent, delivered, read, failed) llegan al mismo webhook dentro de value.statuses, y este bot los ignora. Para tratarlos, mira estado del mensaje vía webhook.

5. Responder texto y botones interactivos

La lógica del bot está en handle. «hola» o «menu» abre el menú de botones; el toque en un botón llega como type: "interactive" con interactive.type: "button_reply" y el id que definiste al enviar.

ANSWERS = {
    "horario": "Atendemos de lunes a viernes, de 9 a 18 h.",
    "precios": "Consulta la lista completa en https://example.com/precios",
    "humano": "¡Listo! Un agente seguirá la conversación por aquí.",
}


async def handle(msg: dict) -> None:
    dest = recipient_of(msg)
    await mark_read(msg["id"])
    kind = msg.get("type")
    if kind == "text":
        text = msg["text"]["body"].strip().lower()
        if text in {"hola", "menu", "menú"}:
            await send_menu(dest)
        else:
            await send_text(dest, "No entendí. Escribe *menu* para ver las opciones.")
    elif kind == "interactive" and msg["interactive"]["type"] == "button_reply":
        choice = msg["interactive"]["button_reply"]["id"]
        await send_text(dest, ANSWERS.get(choice, "Opción no válida."))
    else:
        await send_text(dest, "Por ahora solo entiendo texto y botones.")


def recipient_of(msg: dict) -> dict:
    # Usuarios con nombre de usuario pueden llegar sin "from", solo con el BSUID
    if "from" in msg:
        return {"to": msg["from"]}
    return {"recipient": msg["from_user_id"]}

La función recipient_of prepara el bot para los nombres de usuario de WhatsApp. Según la documentación de Meta, cuando el cliente adopta un nombre de usuario el campo from puede no venir en el webhook, pero el identificador from_user_id (BSUID) llega siempre, y desde julio de 2026 se pueden enviar mensajes a ese identificador en el campo recipient. El artículo sobre usernames y BSUID explica el cambio.

Por último, las funciones que llaman a la API:

async def call_api(payload: dict) -> dict:
    r = await client.post(
        API_URL,
        headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
        json={"messaging_product": "whatsapp", **payload},
    )
    if r.is_error:
        log.error("API %s: %s", r.status_code, r.text)
    r.raise_for_status()
    return r.json()


async def mark_read(message_id: str) -> None:
    try:
        await call_api({
            "status": "read",
            "message_id": message_id,
            "typing_indicator": {"type": "text"},
        })
    except httpx.HTTPError:
        pass  # no bloquea la respuesta


async def send_text(dest: dict, text: str) -> dict:
    return await call_api({
        "recipient_type": "individual",
        **dest,
        "type": "text",
        "text": {"body": text},
    })


async def send_menu(dest: dict) -> dict:
    buttons = [("horario", "Horario"), ("precios", "Precios"), ("humano", "Hablar con agente")]
    return await call_api({
        "recipient_type": "individual",
        **dest,
        "type": "interactive",
        "interactive": {
            "type": "button",
            "body": {"text": "¡Hola! ¿Cómo te puedo ayudar?"},
            "action": {
                "buttons": [
                    {"type": "reply", "reply": {"id": i, "title": t}} for i, t in buttons
                ]
            },
        },
    })

mark_read marca el mensaje como leído y muestra «escribiendo…» al cliente. Meta quita el indicador cuando respondes o después de 25 segundos.

Límites de los botones de respuesta según la documentación de Meta:

CampoLímite
Botones por mensajehasta 3
title del botón20 caracteres
id del botón256 caracteres
body.text1.024 caracteres
footer.text60 caracteres

6. Probar en local

Exporta las variables y levanta el servidor en modo desarrollo, con recarga automática:

export VERIFY_TOKEN="un-texto-cualquiera"
export ACCESS_TOKEN="EAAG..."
export APP_SECRET="tu-clave-secreta"
export PHONE_NUMBER_ID="123456789012345"
fastapi dev main.py

Prueba la verificación y un evento firmado sin depender de Meta:

curl "http://127.0.0.1:8000/webhook?hub.mode=subscribe&hub.verify_token=un-texto-cualquiera&hub.challenge=42"

BODY='{"entry":[{"changes":[{"value":{"messages":[{"from":"5215512345678","id":"wamid.prueba1","type":"text","text":{"body":"menu"}}]}}]}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $NF}')
curl -X POST http://127.0.0.1:8000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  --data-binary "$BODY"

El primer comando debe devolver 42. El segundo devuelve {"ok":true} y hace que el bot envíe el menú al número del campo from (usa tu número de prueba).

Para recibir eventos reales, Meta exige una URL pública con certificado TLS válido. En desarrollo, un túnel lo resuelve: la guía para probar el webhook en local con ngrok muestra el paso a paso. Con la URL pública lista, ve a Panel de apps > WhatsApp > Configuración, completa la URL de callback (https://.../webhook) y el token de verificación, guarda y suscríbete al campo messages.

7. Despliegue simple con Docker y HTTPS

Crea el requirements.txt:

fastapi[standard]
httpx

Y el Dockerfile:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["fastapi", "run", "main.py", "--port", "8000"]

fastapi run es el modo de producción, sin recarga automática. En un servidor con Docker:

docker build -t bot-whatsapp .
docker run -d --restart unless-stopped -p 127.0.0.1:8000:8000 \
  -e VERIFY_TOKEN -e ACCESS_TOKEN -e APP_SECRET -e PHONE_NUMBER_ID \
  bot-whatsapp

Falta el HTTPS. Una opción simple es Caddy, que obtiene y renueva el certificado solo cuando el DNS del dominio apunta al servidor y los puertos 80 y 443 están abiertos. El Caddyfile completo tiene dos líneas:

bot.tuempresa.com
reverse_proxy 127.0.0.1:8000

Actualiza la URL de callback en el panel a https://bot.tuempresa.com/webhook.

Checklist antes de pasar a producción

  • Deduplicación persistente. El set en memoria se pierde en cada reinicio y no se comparte entre procesos. Guarda los ID procesados en Redis o en tu base de datos, con expiración.
  • Cola. Si la respuesta depende de IA, de la base de datos o de sistemas lentos, cambia BackgroundTasks por una cola (Redis, RabbitMQ, SQS) con un worker aparte.
  • Un solo proceso por ahora. Con el estado en memoria, corre un único worker. Antes de escalar, saca el estado del proceso.
  • Registra los errores de la API. El código guarda el cuerpo de error de Meta. La tabla de errores de la API de WhatsApp ayuda a interpretarlos.
  • Secretos fuera del código. Token y clave secreta solo en variables de entorno o en un gestor de secretos.
  • Multimedia. Imágenes, audios y documentos llegan al webhook solo con un ID; la descarga es una llamada aparte a la API de medios.

Cómo hacerlo con Vectalk

Vectalk es un proxy de la WhatsApp Cloud API oficial de Meta: los payloads de envío y los eventos del webhook son los mismos de Meta, así que main.py funciona sin cambios de lógica. Cambian tres cosas:

  1. URL y token. Define API_URL=https://apis.vectalk.com.br/api/messages y usa en ACCESS_TOKEN un token de instancia generado en Vectalk. Con él, el phone_number_id se resuelve automáticamente.
  2. Registro del webhook por API. Registras la URL con POST https://apis.vectalk.com.br/api/webhooks, indicando instance_id, url y name, y puedes tener varias URL por instancia.
  3. Protección del endpoint. La documentación de webhooks de Vectalk no describe un encabezado de firma. Deja APP_SECRET vacío, define WEBHOOK_KEY con un valor aleatorio largo y registra la URL como https://bot.tuempresa.com/webhook?key=TU_VALOR.
curl -X POST https://apis.vectalk.com.br/api/webhooks \
  -H "Authorization: Bearer TU_TOKEN_VECTALK" \
  -H "Content-Type: application/json" \
  -d '{"instance_id": "TU_INSTANCIA", "url": "https://bot.tuempresa.com/webhook?key=TU_VALOR", "name": "Bot Python"}'

Vectalk espera un 200 en menos de 20 segundos y reenvía el evento si no lo recibe, algo que la deduplicación ya resuelve. Crea tu cuenta en apps.vectalk.com.br/register y consulta la documentación para los demás endpoints. Si el siguiente paso es responder con IA, el tutorial para conectar ChatGPT a la API oficial de WhatsApp parte de una base parecida.

Preguntas frecuentes

¿Necesito una librería específica de WhatsApp para Python?

No. La Cloud API es HTTP con JSON, así que FastAPI para recibir y httpx (o requests) para enviar son suficientes. Los SDK de terceros pueden ahorrar código, pero llamar a la API directamente te permite usar las funciones nuevas en cuanto Meta las lanza.

¿El bot puede iniciar la conversación?

El texto libre y los botones solo funcionan dentro de la ventana de 24 horas abierta por el último mensaje del cliente. Para iniciar una conversación, o responder después de ese plazo, hay que enviar una plantilla aprobada.

¿Cuánto cuesta mantener un bot?

Las respuestas dentro de la ventana de atención son mensajes de servicio. Desde el 1 de octubre de 2026, cada número tiene 1.000 mensajes de servicio gratis al mes; a partir del 1.001, la tarifa para destinatarios en Brasil es de R$ 0,0350 (reales brasileños) por mensaje. Las tarifas de otros países están en la página de precios de Meta.

¿Por qué falla la verificación del webhook en el panel?

Las causas más comunes: la URL no es HTTPS con certificado válido, el VERIFY_TOKEN del servidor no coincide con el que escribiste en el panel, o la ruta devuelve el desafío como JSON en lugar de texto plano. Por eso el código usa PlainTextResponse.

¿Puedo usar Flask o Django en lugar de FastAPI?

Sí. La secuencia es la misma: responder al GET de verificación, validar el HMAC sobre el cuerpo crudo, devolver 200 rápido y llamar al endpoint de mensajes. En Flask, el cuerpo crudo se obtiene con request.get_data().


Fuentes oficiales consultadas el 28/10/2026: Create a webhook endpoint (Meta), Webhooks overview (Meta), Interactive reply buttons messages (Meta), Interactive messages webhook (Meta), Typing indicators (Meta), Business-scoped user IDs (Meta), Pricing (Meta), FastAPI, Caddy: reverse proxy quick-start, Vectalk: webhooks, Vectalk: autenticación, Vectalk: botones de respuesta.

Prueba hoy la API oficial de WhatsApp

Crea tu cuenta, genera el token y envía el primer mensaje con la guía de la documentación. Los mismos payloads y webhooks de la Cloud API de Meta.