
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
idda 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
- Quick Start — subir a stack em Docker
- Webhooks — payloads, os 11 tipos de evento, verificação de assinatura
- Enviar mensagens — todos os tipos de mensagem
- Evolution API vs WaSphere — comparação entre os dois gateways open-source mais usados no Brasil