Bot de WhatsApp com IA: arquitetura e custos

Um bot de WhatsApp com IA é um servidor que recebe mensagens da Cloud API da Meta por webhook, consulta um modelo de linguagem e responde. Em um bot que só responde, o maior custo costuma ser o modelo.

Atualizado em 7 min de leitura

Objetivos de aprendizado

Depois de ler este artigo, você vai conseguir:

  • Entender o caminho de uma mensagem do WhatsApp até o LLM e de volta
  • Escrever um webhook em FastAPI que verifica a assinatura e responde com IA
  • Estimar o que a Meta, o modelo e a hospedagem cobram
Nesta página

Um bot de WhatsApp com IA parece complicado, mas é uma API web comum que conversa com dois lados: a Meta, que entrega e envia as mensagens, e um provedor de modelo de linguagem, que escreve a resposta. O seu código fica no meio e decide o que o modelo sabe e quanto você gasta.

A decisão mais importante vem antes do código: usar a Cloud API oficial da Meta ou uma biblioteca não oficial. Aqui usamos a Cloud API, porque é a via suportada e funciona por webhook, sem manter uma sessão do WhatsApp Web aberta.

UsuárioWhatsAppCloud APIoficialmensagemrespostaPOSTPOSTSeu servidorwebhook HTTPScheca assinaturaresponde 200 OKignora repetidasAPI do LLMescreve a respostahistóricotexto
O webhook confere a assinatura e responde 200 na hora; o LLM roda em segundo plano e a resposta volta pela Cloud API.

Como é a arquitetura de um bot de WhatsApp com IA?#

São quatro peças e um caminho de ida e volta:

  1. O usuário manda uma mensagem para o número do bot.
  2. A Cloud API da Meta faz um POST no seu webhook, um endpoint HTTPS público.
  3. Seu servidor valida a assinatura, devolve 200 OK e, em segundo plano, envia o histórico da conversa ao LLM. Para entender o que o modelo faz por dentro, veja o que é um LLM.
  4. A resposta volta por um POST na Cloud API, que entrega ao usuário.

Isso tem duas consequências práticas. O servidor precisa estar sempre ligado, porque a Meta só reenvia a notificação por um tempo limitado. E o webhook precisa de uma URL HTTPS pública, então testar em localhost pede um túnel.

O que você precisa antes de começar?#

  • Uma conta de desenvolvedor da Meta com um app e o produto WhatsApp adicionado. No painel você pega o Phone Number ID, o token de acesso e o App Secret do app.
  • Uma chave de API de um provedor de LLM. O exemplo usa a da Anthropic.
  • Uma versão do Python ainda com suporte e noção de variáveis de ambiente.

Para o passo a passo da conta e do número, sem IA, veja como fazer um bot de WhatsApp.

Como escrever o webhook?#

O requirements.txt:

text
fastapi
uvicorn
httpx
anthropic

E o main.py. Ele faz quatro coisas: responde ao desafio de verificação (GET), confere a assinatura X-Hub-Signature-256 (POST), ignora mensagens repetidas e responde com o modelo em 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"]  # string que você escolhe no painel da 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"]  # no formato vXX.0, do changelog da Meta
MODEL = os.environ["ANTHROPIC_MODEL"]  # ID da lista de modelos da Anthropic
SYSTEM = (
    "Você é o assistente de atendimento da loja Exemplo no WhatsApp. "
    "Responda em português, em no máximo 3 frases, sem markdown."
)

SEND_URL = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{PHONE_ID}/messages"
llm = AsyncAnthropic()  # lê ANTHROPIC_API_KEY do ambiente
app = FastAPI()

history: dict[str, deque] = defaultdict(lambda: deque(maxlen=10))  # RAM: some no restart
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()  # assinatura é sobre o corpo cru, não sobre o 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 imediato; a Meta reenvia se não receber


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()  # o histórico tem de começar 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 blocos de thinking
    except Exception as e:
        print("llm error:", e)
        reply = "Não consegui responder agora. Tente de novo em 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)

O que observar no código:

  • Responda 200 antes de chamar o modelo. Se o endpoint não devolver 200, a Meta tenta de novo, com frequência decrescente por até 7 dias. Sem o seen, o cliente receberia respostas duplicadas.
  • Valide a assinatura. A documentação da Meta diz que a validação não é obrigatória, mas é recomendada. Sem ela, qualquer pessoa que descubra a URL pode mandar mensagens falsas e gastar o seu crédito de IA.
  • from é o número do usuário e serve direto como destino (to) da resposta.
  • O modelo e a versão da Graph API são configuração. A Anthropic aposenta modelos, e cada versão da Graph API deixa de funcionar cerca de dois anos depois do lançamento. Por isso os dois ficam em variáveis de ambiente, sem valor padrão: se faltar uma, o app para ao iniciar com KeyError, e atualizar vira trocar o valor e reiniciar. Copie o ID de um modelo pequeno, rápido e barato (da linha Haiku, por exemplo) da lista de modelos da Anthropic e a versão mais recente do changelog da Graph API.
  • O raciocínio entra no max_tokens. Se o modelo escolhido pensa antes de responder, esses tokens contam no limite e são cobrados como saída. Com max_tokens baixo demais, a resposta pode sair cortada ou vazia. O effort baixo reduz o raciocínio num atendimento simples, e 1024 deixa folga para a resposta curta. Se o modelo não aceitar o parâmetro effort, a API devolve erro 400: remova a linha.
  • O código só trata texto. Áudio, imagem e botões chegam com outro type; adicione um ramo para cada um que o seu bot suportar.

Como configurar e testar o webhook?#

Rode localmente e exponha a porta com um túnel HTTPS:

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

No painel da Meta, na configuração do WhatsApp, informe a URL https://seu-dominio/webhook e o mesmo VERIFY_TOKEN, e assine o campo messages. Para ver a verificação funcionando antes disso:

bash
curl "localhost:8000/webhook?hub.mode=subscribe&hub.verify_token=uma-string-secreta&hub.challenge=123"
echo "(a resposta deve ser 123)"

Depois mande uma mensagem do seu celular para o número do bot e acompanhe os logs do servidor.

Quanto custa um bot de WhatsApp com IA?#

Há três contas separadas:

  • Meta. A cobrança é por template entregue, e não por conversa. Mensagens que não são template (texto, imagem) dentro da janela de atendimento de 24 horas são gratuitas. Um bot que só responde ao cliente fica, em geral, dentro dessa janela. Disparos ativos, como avisos e promoções, usam templates e são cobrados por categoria e país. Veja os detalhes em custo da API oficial do WhatsApp.
  • Modelo. Você paga por token de entrada e saída. Como o histórico é reenviado a cada mensagem, conversas longas custam mais; o maxlen=10 e o max_tokens=1024 do código seguram isso. Para fazer as contas, veja quanto custa uma API de LLM.
  • Hospedagem. Um webhook que só repassa texto usa pouca memória. O que pesa é estar sempre ligado.

Quais são os limites e erros comuns?#

  • Memória some no restart. O history vive na RAM. Para produção, guarde as últimas mensagens num Redis ou banco, indexadas pelo número.
  • Janela de 24 horas. Fora dela, só templates aprovados passam. Se o bot precisa puxar assunto, planeje templates.
  • Falha ao salvar o webhook no painel: o VERIFY_TOKEN do painel é diferente do da variável de ambiente, ou o servidor não está acessível por HTTPS público.
  • Assinatura sempre inválida: o APP_SECRET está errado ou você validou sobre o JSON reserializado em vez de request.body().
  • Respostas duplicadas: o servidor demorou ou falhou ao devolver 200. Confirme que o trabalho do modelo roda em segundo plano.
  • Abuso e prompt injection: o usuário controla o texto que o modelo lê. Não dê ferramentas ou dados sensíveis ao modelo sem filtro e, para volume alto, limite mensagens por número.

Como fazer isso na Square Cloud#

O webhook precisa de duas coisas que o computador de casa não dá: estar no ar sempre que a Meta chamar e uma URL HTTPS pública. Na Square Cloud, a aplicação roda num container isolado e sempre ligado, sem cold start nem modo de suspensão, e ganha um subdomínio *.squareweb.app com HTTPS, que pode servir como URL do webhook no painel da Meta, sem túnel.

  1. Envie main.py e requirements.txt por upload de ZIP no painel, CLI, GitHub ou extensão do VS Code. O arquivo squarecloud.app é opcional: o painel o gera no deploy, ou você o escreve na raiz do projeto.
  2. Use uvicorn main:app --host 0.0.0.0 --port $PORT como comando de início: uma aplicação web escuta em 0.0.0.0, na porta da variável PORT, e usa pelo menos 512 MB do pool de RAM do plano.
  3. Cadastre VERIFY_TOKEN, APP_SECRET, WA_TOKEN, WA_PHONE_NUMBER_ID, GRAPH_API_VERSION, ANTHROPIC_API_KEY e ANTHROPIC_MODEL nas variáveis de ambiente da aplicação. A mudança vale no próximo restart, sem rebuild.
  4. Mande a primeira mensagem com os logs em tempo real abertos: os erros da Meta e do modelo aparecem ali.

A partir do plano Standard (R$ 44,99 por mês), a hospedagem de Redis pode guardar o histórico entre restarts e, no mesmo plano, o AI Gateway pode substituir o provedor do modelo: uma API compatível com a da OpenAI para o modelo cubic, em beta com acesso antecipado sem custo extra. Ele só aceita texto, que é o que este webhook trata. Adicione openai ao requirements.txt, troque a chamada dentro de answer e apague a linha do MODEL, que deixa de ser usada:

python
from openai import AsyncOpenAI

llm = AsyncOpenAI(
    base_url="https://api.squarecloud.app/v2/ai",
    api_key=os.environ["SQUARECLOUD_API_KEY"],  # chave da conta com o escopo 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 "..."

Confira os limites antes de trocar: no Standard, até 2,5 milhões de tokens e até 1.000 requisições por dia, uma requisição por vez. Com muitos clientes escrevendo ao mesmo tempo, uma chamada que chega enquanto outra está em andamento recebe erro 429; o SDK da OpenAI tenta de novo algumas vezes, e o except do código responde com a mensagem de reserva. Limites e planos podem mudar quando o beta acabar. O Hobby, a partir de R$ 12,49 por mês, hospeda o webhook, mas não inclui bancos nem o gateway, e a Meta cobra os templates à parte. Veja os detalhes em bots de WhatsApp.

IA

Hospede apps com IA em containers sempre ligados

Saiba mais

Perguntas frequentes

Posso usar uma biblioteca como Baileys em vez da Cloud API?

Dá, mas bibliotecas como Baileys não são a via oficial da Meta e dependem de uma sessão do WhatsApp Web aberta. A Cloud API é a opção suportada e funciona por webhook, sem sessão para manter.

Por que o bot responde duas vezes à mesma mensagem?

A Meta reenvia a notificação se o seu servidor não devolver 200. Responda 200 logo, faça o trabalho em segundo plano e guarde o id das mensagens já tratadas.

O bot pode responder a qualquer hora?

Com mensagem livre, só enquanto a janela de atendimento de 24 horas estiver aberta, e ela recomeça a cada mensagem do cliente. Fora dela, só templates aprovados pela Meta.

Cada resposta do bot é cobrada pela Meta?

Não. Mensagens que não são templates, enviadas dentro da janela de atendimento aberta, são gratuitas. A Meta cobra por template entregue, conforme a categoria e o país.

Próximo artigoO que é banco de dados vetorialBanco de dados vetorial guarda embeddings (listas de números) e busca os itens mais parecidos em significado, não só em palavras. Veja como funciona e quando usar.