WWaSphere
GitHub
Chatwoot + WhatsApp: integração self-hosted, do zero
WaSphere Teamchatwootintegracaoself-hostedwebhookswhatsapp-api

Chatwoot + WhatsApp: integração self-hosted, do zero

Como ligar o WhatsApp ao Chatwoot self-hosted usando uma API própria — canal API, webhooks assinados, mapeamento de contatos e conversas, e múltiplas caixas de entrada para múltiplos números.

Chatwoot + WhatsApp: integração self-hosted, do zero

O Chatwoot é excelente como central de atendimento e péssimo como gateway de WhatsApp — porque ele não é um. Para colocar o WhatsApp dentro do Chatwoot você precisa de alguma coisa do outro lado que fale WhatsApp de verdade.

O caminho oficial é conectar um provedor da Cloud API da Meta (ou o 360dialog/Twilio). Funciona, é o mais estável, e você paga por conversa. Este guia cobre o outro caminho: um gateway self-hosted que você opera, ligado ao Chatwoot pelo canal API. Nesse arranjo você não paga por mensagem, e nem o Chatwoot nem o WhatsApp saem da sua infraestrutura.

Vamos usar a WaSphere como gateway, mas a arquitetura vale para qualquer API de WhatsApp que tenha webhooks — o padrão é o mesmo.

A arquitetura em uma frase

O Chatwoot tem um tipo de caixa de entrada chamado API channel. Ele não sabe nada sobre WhatsApp: ele recebe mensagens que você empurra por HTTP e, quando um atendente responde, faz um POST para uma URL sua. Você fica no meio, traduzindo.

WhatsApp ──▶ WaSphere ──webhook──▶ [ seu conector ] ──▶ Chatwoot (canal API)
WhatsApp ◀── WaSphere ◀──REST──── [ seu conector ] ◀──webhook── Chatwoot

O "seu conector" é um serviço HTTP pequeno — cem linhas, mais ou menos. É a única coisa que você precisa escrever.

Requisitos

  • Chatwoot self-hosted rodando (Docker ou instalação nativa) e uma conta de admin.
  • WaSphere rodando com um número conectado — siga o Quick Start, leva uns 10 minutos.
  • Uma chave de API da WaSphere com o escopo messages:send. Ver Chaves de API.
  • Um lugar para hospedar o conector (pode ser o mesmo servidor).

Passo 1 — Criar a caixa de entrada API no Chatwoot

No Chatwoot: Settings → Inboxes → Add Inbox → API.

Preencha:

  • Channel Name — algo como WhatsApp Vendas. Esse nome aparece para os atendentes.
  • Webhook URL — a URL do seu conector que vai receber as respostas dos atendentes, por exemplo https://conector.seudominio.com/chatwoot/outgoing.

Ao salvar, o Chatwoot mostra o inbox_identifier. Guarde-o: é com ele que você empurra mensagens para dentro, sem precisar de token de administrador.

Atribua os agentes à caixa de entrada na aba Collaborators, senão ninguém vê as conversas.

Passo 2 — Criar o webhook na WaSphere

Agora o outro lado. Crie um webhook apontando para o seu conector:

curl -X POST "https://api.seudominio.com/workspaces/{workspaceId}/webhooks" \
  -H "Authorization: Bearer wsk_sua_chave" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Chatwoot",
    "url": "https://conector.seudominio.com/wasphere/incoming",
    "events": ["message.received"]
  }'

Assine só message.received. Se quiser refletir status de entrega dentro do Chatwoot depois, acrescente message.delivered e message.read — mas comece pelo mínimo.

Cada entrega chega com assinatura:

X-WaSphere-Event: message.received
X-WaSphere-Signature: v1,sha256=<hmac-hex>
X-WaSphere-Timestamp: 1748168400

A assinatura é HMAC-SHA256 sobre a string {timestamp}.{rawBody}. Valide sempre — sem isso, qualquer pessoa que descubra a URL do conector injeta conversas falsas no seu atendimento. O detalhamento e exemplos em Node, Python e PHP estão em Webhooks.

Passo 3 — Do WhatsApp para o Chatwoot

Quando chega uma mensagem, o conector faz três chamadas ao Chatwoot: garante o contato, garante a conversa, cria a mensagem.

O contato usa o número do WhatsApp como identificador estável:

# 1. Contato (idempotente pelo identifier)
curl -X POST \
  "https://chatwoot.seudominio.com/public/api/v1/inboxes/$INBOX_IDENTIFIER/contacts" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "5511999999999",
    "name": "Ana Souza",
    "phone_number": "+5511999999999"
  }'
# -> resposta contém "source_id"
# 2. Conversa
curl -X POST \
  "https://chatwoot.seudominio.com/public/api/v1/inboxes/$INBOX_IDENTIFIER/contacts/$SOURCE_ID/conversations" \
  -H "Content-Type: application/json"
# -> resposta contém "id" da conversa
# 3. Mensagem (incoming = veio do cliente)
curl -X POST \
  "https://chatwoot.seudominio.com/public/api/v1/inboxes/$INBOX_IDENTIFIER/contacts/$SOURCE_ID/conversations/$CONVERSATION_ID/messages" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Oi, meu pedido chegou?" }'

Em código, com o mapeamento em memória (troque por Redis ou uma tabela em produção):

const CW = 'https://chatwoot.seudominio.com';
const INBOX = process.env.CHATWOOT_INBOX_IDENTIFIER;
const map = new Map(); // telefone -> { sourceId, conversationId }

async function toChatwoot(phone, name, text) {
  let entry = map.get(phone);

  if (!entry) {
    const contact = await post(
      `${CW}/public/api/v1/inboxes/${INBOX}/contacts`,
      { identifier: phone, name, phone_number: `+${phone}` },
    );
    const sourceId = contact.source_id;

    const convo = await post(
      `${CW}/public/api/v1/inboxes/${INBOX}/contacts/${sourceId}/conversations`,
      {},
    );

    entry = { sourceId, conversationId: convo.id };
    map.set(phone, entry);
  }

  await post(
    `${CW}/public/api/v1/inboxes/${INBOX}/contacts/${entry.sourceId}` +
      `/conversations/${entry.conversationId}/messages`,
    { content: text },
  );
}

Uma nota sobre o identifier: use o número em formato internacional, sem + e sem espaços, e use exatamente o mesmo formato nos dois sentidos. A causa mais comum de "conversas duplicadas no Chatwoot" é o mesmo contato entrando como 5511999999999 numa hora e +55 11 99999-9999 noutra.

Passo 4 — Do Chatwoot para o WhatsApp

Quando um atendente responde, o Chatwoot faz POST na Webhook URL da caixa de entrada. O payload traz message_type: "outgoing" e o conteúdo. Só encaminhe as saídas — se você reenviar tudo, cria um laço infinito com as mensagens que você mesmo acabou de inserir.

app.post('/chatwoot/outgoing', async (req, res) => {
  const { message_type, content, conversation, private: isPrivate } = req.body;

  // ignore mensagens de entrada (que fomos nós que criamos) e notas privadas
  if (message_type !== 'outgoing' || isPrivate) return res.sendStatus(200);

  const phone = conversation?.meta?.sender?.identifier;
  if (!phone || !content) return res.sendStatus(200);

  await fetch(
    `https://api.seudominio.com/workspaces/${WORKSPACE_ID}` +
      `/proxy/api/sessions/${SESSION_ID}/messages/text`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.WASPHERE_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ to: phone, text: content }),
    },
  );

  res.sendStatus(200);
});

Duas armadilhas conhecidas:

  • Notas privadas. O Chatwoot envia notas internas pelo mesmo webhook, marcadas com private: true. Se você não filtrar, a anotação interna do atendente vai parar no WhatsApp do cliente. Esse erro só acontece uma vez, mas acontece com todo mundo.
  • Idempotência. O Chatwoot pode reentregar. Guarde o id da mensagem já processada e descarte repetições.

Múltiplos números, múltiplas caixas de entrada

Aqui a arquitetura fica útil. A WaSphere é multi-sessão: um deploy roda vários números do WhatsApp, isolados entre si. O mapeamento natural é uma sessão da WaSphere = uma caixa de entrada do Chatwoot.

Na prática: crie uma caixa de entrada API por número (WhatsApp Vendas, WhatsApp Suporte), e no conector guarde um dicionário sessionId -> inbox_identifier. O sessionId vem no corpo de todo webhook da WaSphere, então o roteamento é direto:

const INBOXES = {
  'vendas':  process.env.CW_INBOX_VENDAS,
  'suporte': process.env.CW_INBOX_SUPORTE,
};

Você pode ainda criar uma chave de API por sessão, presa àquela sessão — assim o conector de vendas nem consegue mandar mensagem pelo número do suporte, mesmo se a chave vazar.

E as mídias?

O canal API do Chatwoot aceita anexos via multipart/form-data no endpoint de mensagens. O fluxo é: baixar a mídia pela WaSphere, reenviar como attachments[] ao Chatwoot. No sentido oposto, o payload do Chatwoot traz uma lista attachments com data_url, e você envia pela WaSphere usando o endpoint do tipo certo (/messages/image, /messages/document, e assim por diante) com o campo url.

Comece só com texto. Coloque no ar, veja funcionando, depois some mídia. Um conector de texto que funciona hoje é melhor do que um conector completo que você ainda está depurando na semana que vem.

Vale a pena manter os dois?

Vale a pena perguntar. A WaSphere já traz uma caixa de entrada em tempo real no próprio dashboard — duas colunas, atualização por SSE, mídia inline, tags, notas e busca. Para times pequenos, isso já é o atendimento inteiro, e você economiza um serviço, um banco e um conector.

O Chatwoot ganha quando você precisa do que ele faz de verdade bem: múltiplos canais no mesmo lugar (e-mail, chat do site, Instagram, WhatsApp), relatórios de SLA, automações e macros, e uma base de conhecimento. Se o WhatsApp é só um dos canais que o seu time atende, o Chatwoot é a escolha certa e este guia é o caminho mais barato para chegar lá.

Se o WhatsApp é o único canal, experimente o Inbox nativo antes de montar a integração — pode ser que você não precise dela.

Próximos passos

Get Started

Ship a self-hosted WhatsApp API today

Clone the repo, set your secrets, and send your first message in minutes — Docker-based, MIT licensed, zero config.

Read the Quick Start