MÓDULO 4.2

📱 Integrando Evolution API (WhatsApp)

Conecte WhatsApp em 10 minutos com Docker, gere QR code, receba mensagens via webhook e envie texto, áudio, imagem e PDF — sem aprovação Meta, com margem de 70%.

6
Tópicos
75
Minutos
Avançado
Nível
Integração
Tipo
1

🤔 O que é Evolution API e por que escolhemos

Evolution API é wrapper open-source em volta do Baileys (cliente WhatsApp Web não-oficial). Você sobe num Docker, conecta um número escaneando QR, e tem WhatsApp programável em minutos — sem a burocracia da Cloud API oficial da Meta.

Critério Evolution API WhatsApp Cloud (Meta)
Tempo até funcionar10 min2 a 6 semanas
Aprovação Meta / BSPnão precisaobrigatória
Custo por mensagemR$ 0R$ 0,02–0,40
Conformidade ToScinzaoficial
Risco de banimentomédiozero
Templates aprovadoslivresó template aprovado
Recomendado praPME, MVP, atendimentogrande volume, marketing

📌 O trade-off honesto

Evolution não é "production-grade enterprise". Existe risco do número ser bloqueado se você fizer disparo em massa indevido. Pra atendimento (cliente fala primeiro, você responde), o risco é baixíssimo. Pra PME brasileira de até 5k mensagens/mês, é a escolha vencedora hoje — e quando o cliente crescer pra 100k/mês, migra pra Cloud API mantendo o mesmo backend.

2

☁️ Self-host vs cloud

Você pode rodar Evolution na sua própria VPS por R$ 30/mês ou pagar um serviço gerenciado. Margem do seu SaaS depende dessa decisão.

🖥️ Self-host (VPS)

R$ 30/mês
Hetzner CX22 ou Hostinger VPS 2
  • Controle total: backup, logs, atualização
  • Margem alta — sustenta 5 a 10 clientes na mesma VPS
  • Sem dependência de fornecedor que pode mudar regra
  • !Você responde plantão se cair de madrugada
  • !Precisa configurar SSL, firewall, backup

☁️ Provedor SaaS

R$ 100–300/mês
UltraMsg, Z-API, Wppconnect Cloud
  • Sem dor de operação — sobe e usa
  • Suporte e SLA contratual
  • !Margem baixa — não sustenta cliente de R$ 200/mês
  • !Lock-in: trocar depois exige reescrever integração
  • Cliente perceptivelmente mais devagar

💡 A recomendação prática

Comece com SaaS pago no primeiro cliente pra validar produto. Quando chegar no terceiro cliente pagante, migre pra self-host. O ponto de equilíbrio é 3 clientes — abaixo disso, paga o cloud e foca em vender; acima, opera pra capturar margem.

3

🐳 Setup local com Docker

Um arquivo, um comando, dez minutos. docker-compose up sobe Evolution + Postgres + Redis e expõe um painel web em http://localhost:8080.

📄 docker-compose.yml

YAML
version: "3.9"

services:
  evolution:
    image: atendai/evolution-api:latest
    container_name: evolution_api
    restart: always
    ports:
      - "8080:8080"
    environment:
      AUTHENTICATION_API_KEY: ${EVOLUTION_API_KEY}
      DATABASE_ENABLED: "true"
      DATABASE_PROVIDER: postgresql
      DATABASE_CONNECTION_URI: postgresql://evo:${DB_PASS}@postgres:5432/evolution
      REDIS_ENABLED: "true"
      REDIS_URI: redis://redis:6379
      WEBHOOK_GLOBAL_URL: ${WEBHOOK_URL}
      WEBHOOK_GLOBAL_ENABLED: "true"
      QRCODE_LIMIT: "30"
    depends_on:
      - postgres
      - redis
    volumes:
      - evolution_instances:/evolution/instances

  postgres:
    image: postgres:15-alpine
    restart: always
    environment:
      POSTGRES_USER: evo
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: evolution
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U evo"]
      interval: 10s

  redis:
    image: redis:7-alpine
    restart: always
    volumes:
      - redis_data:/data

volumes:
  evolution_instances:
  postgres_data:
  redis_data:

📄 .env

bash
EVOLUTION_API_KEY=troque-isso-por-uuid-forte
DB_PASS=outro-uuid-forte
WEBHOOK_URL=https://seu-app.convex.site/webhooks/evolution

🚀 Subir e validar

# subir
docker compose up -d

# verificar saúde
curl http://localhost:8080/

# logs em tempo real
docker compose logs -f evolution

# parar tudo
docker compose down

# reset completo (apaga sessões)
docker compose down -v

⚠️ Uma armadilha comum

Webhook usa localhost dá pau porque o container não enxerga o seu host. Use ngrok em desenvolvimento (ngrok http 3000) e coloque a URL HTTPS retornada como WEBHOOK_URL. Em produção, é a URL da Vercel ou Convex.

4

📲 Conectando WhatsApp pessoal

Cada cliente tem uma "instância" no Evolution. Você cria, mostra QR code na sua UI, ele escaneia, e a sessão fica salva. Cair e reconectar deve ser invisível pro cliente.

🔄 Ciclo de vida de uma instância

1️⃣
Criar instância
POST /instance/create { instanceName: "cliente_abc" }
2️⃣
Buscar QR code
GET /instance/connect/cliente_abc
3️⃣
Cliente escaneia no celular
WhatsApp → Aparelhos conectados → Conectar
4️⃣
Sessão persistida no Postgres
Reinício do container = reconnect automático
5️⃣
Status connected → manda e recebe
GET /instance/connectionState/cliente_abc

📋 Boas práticas pra não cair

Use número dedicado, nunca o pessoal
Aqueça o número 1–2 semanas antes de volume alto
Polling de connectionState a cada 5 min com alerta
UI no painel mostrando status (verde/vermelho)
Disparo em massa pra contato que não falou
Mensagem idêntica copiada 100 vezes seguidas
Mais de 1 sessão simultânea no mesmo número
Reescanear QR de hora em hora — algo está errado
5

🔔 Webhooks: receber mensagens

Webhook é o coração do produto. Toda vez que chega mensagem no WhatsApp do cliente, Evolution dá POST no seu endpoint. Você valida, salva no banco e dispara o agente IA.

🌊 Fluxo do webhook

WhatsApp cliente envia Evolution capta + POST Webhook valida HMAC grava + dispara Convex message + AI

📄 convex/http.ts (handler do webhook)

TypeScript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";

const http = httpRouter();

http.route({
  path: "/webhooks/evolution",
  method: "POST",
  handler: httpAction(async (ctx, req) => {
    // 1. valida segredo no header
    const auth = req.headers.get("apikey");
    if (auth !== process.env.EVOLUTION_API_KEY) {
      return new Response("unauthorized", { status: 401 });
    }

    const body = await req.json();
    if (body.event !== "messages.upsert") return new Response("ok");

    const msg = body.data;
    if (msg.key.fromMe) return new Response("ok"); // ignora as nossas

    // 2. idempotência: se já processei este id, sai
    const existing = await ctx.runQuery(internal.messages.byExternalId, {
      externalId: msg.key.id,
    });
    if (existing) return new Response("ok");

    // 3. resolve contato e conversa
    const phone = msg.key.remoteJid.split("@")[0];
    const { contactId, conversationId } = await ctx.runMutation(
      internal.contacts.upsertAndOpen,
      { instanceName: body.instance, phone, name: msg.pushName }
    );

    // 4. grava mensagem
    await ctx.runMutation(internal.messages.create, {
      conversationId,
      externalId: msg.key.id,
      direction: "inbound",
      sender: "contact",
      body: msg.message?.conversation ?? "[mídia]",
      sentAt: msg.messageTimestamp * 1000,
    });

    // 5. agenda agente IA
    await ctx.scheduler.runAfter(0, internal.ai.respond, { conversationId });

    return new Response("ok");
  }),
});

export default http;

📌 Os 3 pecados capitais do webhook

1) Não validar idempotência — Evolution reentrega em retry e sua UI mostra mensagem duplicada. 2) Processar tudo no handler — bloqueia, dá timeout, perde mensagem. Use scheduler.runAfter(0, ...). 3) Engolir erro silenciosamente — sempre logue com nível ERROR e dispare alerta. Mensagem perdida é a queixa nº 1 de cliente nesse domínio.

6

📤 Enviando mensagens

Texto, imagem, áudio, document. Cada tipo tem seu endpoint, mas a função pode ser uma só com switch — facilita usar do agente IA.

📄 convex/lib/whatsapp.ts

TypeScript
type SendPayload =
  | { kind: "text"; text: string }
  | { kind: "image"; url: string; caption?: string }
  | { kind: "audio"; url: string }
  | { kind: "document"; url: string; filename: string };

export async function sendMessage(
  instanceName: string,
  to: string,
  payload: SendPayload
) {
  const base = process.env.EVOLUTION_BASE_URL!;
  const headers = {
    "Content-Type": "application/json",
    apikey: process.env.EVOLUTION_API_KEY!,
  };

  const number = to.replace(/\D/g, "") + "@s.whatsapp.net";

  const routes = {
    text:     { path: "sendText",     body: { number, text: (payload as any).text } },
    image:    { path: "sendMedia",    body: { number, mediatype: "image",
                  media: (payload as any).url, caption: (payload as any).caption } },
    audio:    { path: "sendWhatsAppAudio", body: { number, audio: (payload as any).url } },
    document: { path: "sendMedia",    body: { number, mediatype: "document",
                  media: (payload as any).url, fileName: (payload as any).filename } },
  } as const;

  const route = routes[payload.kind];
  const res = await fetch(`${base}/message/${route.path}/${instanceName}`, {
    method: "POST", headers, body: JSON.stringify(route.body),
  });

  if (!res.ok) throw new Error(`evolution ${res.status}: ${await res.text()}`);
  return res.json();
}

📄 Exemplo de payload JSON (envio de texto)

JSON
{
  "number": "5511987654321@s.whatsapp.net",
  "text": "Olá Ana, posso te confirmar a reunião amanhã às 14h?"
}

⚖️ Limites e boas práticas

Volume sustentável
  • • Máx 1 msg/segundo por instância
  • • Pausa de 3–8s entre mensagens em sequência
  • • Atrasinho aleatório imita "digitando"
  • • Até 5k msgs/dia em número aquecido
Mídia
  • • Imagem: até 5MB, JPEG/PNG
  • • Áudio: ogg/opus pra "voice note"
  • • Documento: até 100MB, PDF preferido
  • • URL pública preferida sobre base64

💡 Áudio é o segredo brasileiro

Cliente brasileiro adora áudio do WhatsApp. Use TTS (ElevenLabs ou OpenAI) pra gerar resposta em ogg/opus quando o lead também mandou áudio. Taxa de resposta sobe 40% versus texto puro. Detalhe que faz o produto parecer humano sem ser.

O que Aprendemos

Evolution API destrava WhatsApp em 10 min sem aprovação Meta — trade-off certo pra PME BR.
Self-host R$ 30/mês compensa a partir do 3º cliente — antes disso, paga SaaS e foca em vender.
docker-compose up sobe Evolution + Postgres + Redis num comando — mesmo arquivo dev e prod.
Sessão persistida sobrevive a restart sem novo QR — UX invisível pro cliente.
Webhook idempotente e assíncrono é o coração do produto — mensagem perdida é o pior bug.
Função única sendMessage com switch por tipo — facilita o agente IA usar.

Próximo Módulo:

4.3 — Agente IA dentro do produto: system prompt do qualificador, function calling pra criar deals, handoff humano e otimização de custo via prompt caching.