No Brasil, atendimento acontece no WhatsApp. Não no chat do site, não por e-mail: no WhatsApp. Por isso um chatbot com IA nesse canal é, para a maioria das empresas daqui, o projeto de IA com retorno mais rápido que existe.

Este tutorial monta um do zero: recebimento de mensagem, resposta com LLM, memória de conversa, passagem para humano e controle de custo. Com código que roda e com as armadilhas que só aparecem em produção.

⚡ Resumo

WhatsApp Cloud API (oficial, da Meta) + um webhook em Python + a API por token da GPUBrasil. Custo de IA por conversa: cerca de R$ 0,01 no modelo econômico. O custo de mensageria da Meta é cobrado à parte, por eles.

Antes de escrever código: a escolha que define o projeto

Existem dois caminhos para conectar no WhatsApp, e o segundo é uma armadilha frequente:

Cloud API (oficial)Bibliotecas não oficiais
Como funcionaAPI da Meta, contrato formalAutomatizam o WhatsApp Web
Risco de banimentoNenhumAlto — e o número banido não volta
CustoCobrado pela Meta, por conversa/mensagem"Grátis" até o número cair
EstabilidadeContratualQuebra a cada atualização do WhatsApp
Para que serveQualquer coisa sériaProtótipo pessoal, no máximo

Se o bot vai atender cliente da sua empresa, use a Cloud API oficial. Perder o número comercial da empresa por causa de uma biblioteca não oficial é um prejuízo que nenhuma economia justifica. O resto deste guia assume a via oficial.

Passo 1: as credenciais da Meta

A burocracia é chata mas é uma vez só:

  1. Crie um app em developers.facebook.com e adicione o produto WhatsApp.
  2. Você recebe um número de teste e um token temporário — o bastante para desenvolver hoje.
  3. Para produção: verifique a empresa no Gerenciador de Negócios e conecte um número real (um que não tenha WhatsApp comum ativo).
  4. Gere um token permanente por usuário de sistema. O temporário expira em 24 horas e vai te derrubar no pior momento.

Anote: PHONE_NUMBER_ID, WHATSAPP_TOKEN e um VERIFY_TOKEN que você inventa.

Passo 2: o webhook

A Meta manda cada mensagem recebida para uma URL sua, via POST. Antes, ela valida essa URL com um GET — e ele precisa devolver o challenge em texto puro, senão o cadastro não passa.

import os, hmac, hashlib, httpx
from fastapi import FastAPI, Request, Response, BackgroundTasks

app = FastAPI()

VERIFY   = os.environ["VERIFY_TOKEN"]
TOKEN    = os.environ["WHATSAPP_TOKEN"]
PHONE_ID = os.environ["PHONE_NUMBER_ID"]
SEGREDO  = os.environ["META_APP_SECRET"]
GRAPH    = f"https://graph.facebook.com/v21.0/{PHONE_ID}/messages"


@app.get("/webhook")
def verificar(request: Request):
    p = request.query_params
    if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == VERIFY:
        return Response(content=p.get("hub.challenge"), media_type="text/plain")
    return Response(status_code=403)


@app.post("/webhook")
async def receber(request: Request, tarefas: BackgroundTasks):
    corpo = await request.body()
    if not assinatura_valida(corpo, request.headers.get("x-hub-signature-256", "")):
        return Response(status_code=403)

    dados = await request.json()
    for entrada in dados.get("entry", []):
        for mud in entrada.get("changes", []):
            for msg in mud.get("value", {}).get("messages", []):
                tarefas.add_task(processar, msg)

    return Response(status_code=200)      # 200 IMEDIATO. Ver aviso abaixo.


def assinatura_valida(corpo: bytes, cabecalho: str) -> bool:
    esperado = "sha256=" + hmac.new(SEGREDO.encode(), corpo, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, cabecalho)
⚠️ Responda 200 antes de pensar

Se o seu webhook demora para responder, a Meta considera falha e reenvia a mesma mensagem. Você chama o LLM duas, três vezes, paga por todas e o cliente recebe respostas repetidas.

A regra: valide a assinatura, jogue o processamento para segundo plano e devolva 200 em milissegundos. Em volume maior, troque o BackgroundTasks por uma fila de verdade.

E guarde o id de cada mensagem já processada: reenvio acontece de qualquer jeito, e a idempotência é o que impede cobrança dupla.

Passo 3: responder com o LLM

Agora a parte de IA. A API da GPUBrasil é compatível com a da OpenAI, então qualquer biblioteca do ecossistema serve — e a cobrança é em reais, o que facilita a conta por conversa.

from openai import OpenAI

llm = OpenAI(base_url="https://gpubrasil.com.br/v1",
             api_key=os.environ["GPUBRASIL_API_KEY"])

SISTEMA = """Você é o atendente virtual da {empresa}.

O que você faz:
- Responde dúvidas sobre produtos, prazos, formas de pagamento e horário.
- Consulta o status de um pedido quando o cliente informar o número.

Como responde:
- Português do Brasil, cordial e direto. No máximo 3 frases.
- Sem markdown: WhatsApp não renderiza. Use *negrito* do próprio WhatsApp.
- Uma pergunta por vez.

Limites (importante):
- NUNCA invente prazo, preço, promoção ou política.
- Se não souber, ou se o cliente pedir humano, reclamar ou falar de cancelamento,
  responda exatamente: TRANSFERIR_HUMANO
"""


async def processar(msg: dict):
    de = msg["from"]
    if msg.get("type") != "text":
        await enviar(de, "Por enquanto consigo ler só mensagens de texto. "
                         "Pode escrever o que você precisa?")
        return

    texto = msg["text"]["body"]
    if ja_processada(msg["id"]):
        return
    marcar_processada(msg["id"])

    historico = carregar_historico(de)
    mensagens = ([{"role": "system", "content": SISTEMA.format(empresa="Sua Empresa")}]
                 + historico
                 + [{"role": "user", "content": texto}])

    r = llm.chat.completions.create(
        model="gpub-fast",           # atendimento é tarefa objetiva: modelo econômico
        messages=mensagens,
        temperature=0.3,
        max_tokens=300,              # trava dura: resposta longa no WhatsApp é ruim E cara
    )
    resposta = r.choices[0].message.content.strip()
    registrar_custo(de, r.model, r.usage)

    if "TRANSFERIR_HUMANO" in resposta:
        await enviar(de, "Vou chamar alguém do time para te ajudar. Um instante 🙂")
        abrir_ticket(de, historico + [{"role": "user", "content": texto}])
        return

    salvar_historico(de, texto, resposta)
    await enviar(de, resposta)


async def enviar(para: str, texto: str):
    async with httpx.AsyncClient(timeout=20) as c:
        await c.post(GRAPH,
            headers={"Authorization": f"Bearer {TOKEN}"},
            json={"messaging_product": "whatsapp", "to": para,
                  "type": "text", "text": {"body": texto[:4096]}})

Três detalhes do prompt que evitam incidente

Passo 4: memória de conversa

Sem histórico, o cliente diz "quero dois" e o bot pergunta "dois do quê?". Com histórico ilimitado, você paga por uma conversa de três semanas em toda mensagem. O meio-termo: últimas N trocas, com expiração.

import json, time, redis
r = redis.Redis(decode_responses=True)

JANELA = 6 * 2          # 6 trocas = 12 mensagens
EXPIRA = 60 * 60 * 6    # 6 horas de silêncio → conversa nova

def carregar_historico(tel: str):
    return [json.loads(x) for x in r.lrange(f"hist:{tel}", -JANELA, -1)]

def salvar_historico(tel: str, pergunta: str, resposta: str):
    chave = f"hist:{tel}"
    r.rpush(chave, json.dumps({"role": "user",      "content": pergunta}),
                   json.dumps({"role": "assistant", "content": resposta}))
    r.ltrim(chave, -JANELA, -1)
    r.expire(chave, EXPIRA)

A expiração faz mais do que economizar: cliente que volta no dia seguinte com outro assunto não quer o bot preso no contexto anterior.

Passo 5: dar braços ao bot

Um bot que só conversa tem valor limitado. O que resolve chamado é consultar o seu sistema. O padrão mais robusto é fazer o modelo devolver uma intenção estruturada, e o seu código decidir o que executar:

ROTEADOR = """Classifique a mensagem do cliente. Responda só JSON:
{"intencao": "pedido|duvida|humano|saudacao", "numero_pedido": "se houver, senão null"}"""

def rotear(texto: str) -> dict:
    r = llm.chat.completions.create(
        model="gpub-fast",
        messages=[{"role": "system", "content": ROTEADOR},
                  {"role": "user",   "content": texto}],
        temperature=0,
        response_format={"type": "json_object"},
    )
    try:
        return json.loads(r.choices[0].message.content)
    except json.JSONDecodeError:
        return {"intencao": "duvida", "numero_pedido": None}   # sempre tenha o caminho de erro


def responder(texto, tel):
    rota = rotear(texto)
    if rota["intencao"] == "pedido" and rota["numero_pedido"]:
        p = erp.buscar_pedido(rota["numero_pedido"])           # SEU sistema, dado real
        if not p:
            return "Não localizei esse número. Pode conferir?"
        return f"Pedido *{p.numero}*: {p.status}. Previsão: {p.previsao}."
    ...

Repare que o status do pedido é lido do seu sistema e formatado por código, não gerado pelo modelo. Prazo e status são exatamente o tipo de informação que um LLM inventa com toda a confiança do mundo. Deixe o modelo entender a pergunta; deixe o seu código responder o fato.

Passo 6: a janela de 24 horas

Regra da Meta que pega todo mundo de surpresa: você só pode enviar mensagem livre dentro de 24 horas após a última mensagem do cliente. Passou disso, só com template aprovado previamente.

Guarde o instante da última mensagem de cada cliente e verifique antes de enviar. Tentar mandar texto livre fora da janela retorna erro — e o cliente simplesmente não recebe.

A conta: quanto custa por conversa

Duas contas separadas, e vale entender as duas:

ItemQuem cobraOrdem de grandeza
Mensageria (a conversa em si)MetaConsulte a tabela vigente da Meta — varia por país e categoria
Inteligência (o LLM)GPUBrasil, em reais~R$ 0,01 por conversa no gpub-fast
Hospedagem do webhookOnde você rodarInstância de CPU resolve — não precisa de GPU

Detalhando a parte de IA, numa conversa típica de 10 trocas (cerca de 16 mil tokens de entrada acumulados e 1.500 de saída):

ModeloPor conversa5.000 conversas/mês
gpub-fastR$ 0,0095R$ 48
gpub-miniR$ 0,0147R$ 73
gpub-plusR$ 0,0170R$ 85
gpub-baseR$ 0,1532R$ 766

Para atendimento, o gpub-fast costuma bastar — a tarefa é objetiva e o prompt faz a maior parte do trabalho. Suba de modelo apenas se medir erro de compreensão, não por precaução.

💡 Precisa de GPU dedicada para isso?

Quase nunca. O webhook roda tranquilo numa instância de CPU e a inteligência vem por token, sem custo parado. GPU dedicada só entra se você quiser servir o modelo internamente por exigência de governança, ou se o volume for muito alto e constante.

Passo 7: os cuidados que evitam processo

Erros de produção que você vai encontrar

SintomaCausa
Cliente recebe a mesma resposta 2–3 vezesWebhook lento → reenvio da Meta. Devolva 200 na hora e use idempotência por id
Cadastro do webhook não passaO GET não devolveu o challenge em texto puro
Bot mudo depois de 24 hJanela fechada — exige template aprovado
Asteriscos aparecem no textoModelo usou markdown; reforce no prompt
Parou de funcionar do nadaToken temporário expirou — gere o permanente
Bot inventou prazo de entregaInformação factual veio do modelo em vez do seu sistema
Custo explodiuHistórico sem ltrim, ou max_tokens sem trava

A inteligência do seu bot, cobrada em reais

API compatível com a da OpenAI a partir de R$ 0,49 por milhão de tokens, no mesmo saldo das suas máquinas. Sem assinatura, sem câmbio, com suporte em português.

Criar conta →

Conclusão

Um chatbot de WhatsApp com IA é um projeto de duas semanas, não de dois meses. A parte de LLM é a mais simples — e a mais barata, a centavos por conversa. O que separa um bot que ajuda de um que irrita está nos detalhes operacionais: responder 200 na hora, não repetir mensagem, saber transferir para humano e nunca deixar o modelo inventar um prazo.

Comece pequeno: um bot que responde as cinco perguntas mais frequentes e transfere o resto já tira carga real do time. Aumente o escopo com base no que aparecer nos logs, não no que você imagina que o cliente vai perguntar.

Continue: agente com ferramentas · responder com base nos seus documentos · n8n, se preferir sem código