Como ligar o Typebot ao WhatsApp oficial
O Typebot desenha o fluxo; o CRPRO Hub conecta o número oficial e entrega as mensagens. Entre os dois fica uma ponte pequena: ela recebe o webhook do Hub, chama a API de chat do Typebot e devolve as respostas pela API do Hub. Este guia mostra a ponte inteira, com o mapeamento de botões e listas e o cuidado com a sessão de cada contato.
Por que uma ponte, e não a integração nativa
O Typebot tem integração própria com o WhatsApp, mas ela exige um aplicativo seu na Meta — credenciais, verificação e manutenção por sua conta — e a documentação dela ainda diz que o número não pode estar no aplicativo WhatsApp Business. Pela ponte, o número entra no CRPRO Hub por coexistência e continua no celular, e o Typebot só precisa de um bot publicado. O próprio Typebot documenta esse padrão como integração com aplicativos de mensagem externos.
O que você precisa
- Um bot publicado no Typebot e o id público dele, que fica na aba Share. Bot publicado não exige token para a API de chat.
- Um canal conectado no CRPRO Hub e uma chave de API com permissão de envio — veja o início rápido.
- Um endpoint de webhook assinando
message.received, apontando para a ponte — veja configurar webhook. - Um lugar para guardar a sessão do Typebot de cada contato: Redis, banco ou uma tabela do n8n.
Como funciona
- O contato escreve. O Hub entrega
message.receivedna ponte, que verifica a assinatura, deduplica peloiddo evento e responde2xxlogo. - Sem sessão para aquele contato, a ponte chama
startChat. Com sessão, chamacontinueChatmandando o que o contato escreveu. - O Typebot devolve as bolhas de mensagem e o próximo campo de entrada. A ponte converte cada bolha num envio para a API do Hub.
- Sessões expiram por inatividade, e aí o
continueChatresponde404: a ponte abre uma sessão nova, e o fluxo recomeça do início, como numa conversa nova.
message.received, ou filtre por ele. Cada resposta que a ponte envia gera message.sent e message.status; um endpoint que recebe todos os eventos e não filtra manda a resposta do bot de volta para o bot.Do Typebot para o WhatsApp
Peça as respostas com textBubbleContentFormat: "markdown": o texto vem como string, pronto para o campo text.body. Imagem, áudio e vídeo em arquivo viram envios por link (só HTTPS); vídeo do YouTube ou do Vimeo não é arquivo, e o WhatsApp só aceita MP4, então ele vai como texto com o link. Uma pergunta de múltipla escolha vira botões até três opções e lista de quatro a dez, com o id de cada opção igual ao id do item no Typebot; acima de dez, vira texto numerado.
// Converte a resposta do Typebot (startChat ou continueChat, pedida com
// textBubbleContentFormat: 'markdown') nos corpos de envio do CRPRO Hub.
function paraHub(resposta, para) {
const envios = []
const textos = []
const despejarTextos = () => {
for (const texto of textos.splice(0)) {
envios.push({ to: para, type: 'text', text: { body: texto.slice(0, 4096) } })
}
}
for (const m of resposta.messages ?? []) {
if (m.type === 'text' && m.content?.markdown) {
textos.push(m.content.markdown)
} else if (paraLink(m)) {
despejarTextos()
envios.push({ to: para, type: m.type, [m.type]: { link: m.content.url } })
} else if (m.type === 'video' && m.content?.url) {
textos.push(m.content.url)
}
}
const itens = resposta.input?.type === 'choice input' ? resposta.input.items : []
if (itens.length > 0 && itens.length <= 10) {
// O ultimo texto vira o corpo da pergunta, como no runtime de WhatsApp
// do proprio Typebot.
const pergunta = textos.pop() ?? 'Escolha uma opção:'
despejarTextos()
envios.push(perguntaComOpcoes(para, pergunta, itens))
} else {
if (itens.length > 10) {
textos.push(itens.map((item, i) => `${i + 1}. ${item.content}`).join('\n'))
}
despejarTextos()
}
return envios
}
// Imagem, audio e video por link HTTPS. Video do YouTube, Vimeo e similares
// nao e arquivo: o WhatsApp so aceita MP4, entao ele vai como texto com o link.
function paraLink(m) {
if (!m.content?.url?.startsWith('https://')) return false
if (m.type === 'image' || m.type === 'audio') return true
return m.type === 'video' && m.content.type === 'url'
}
// Ate 3 opcoes viram botoes (titulo de ate 20 caracteres); de 4 a 10, lista
// (ate 24). O id de cada opcao e o id do item no Typebot.
function perguntaComOpcoes(para, pergunta, itens) {
const titulo = (item, i, max) => (item.content || `Opção ${i + 1}`).slice(0, max)
const body = { text: pergunta.slice(0, 1024) }
if (itens.length <= 3) {
const buttons = itens.map((item, i) => ({ type: 'reply', reply: { id: item.id, title: titulo(item, i, 20) } }))
return { to: para, type: 'interactive', interactive: { type: 'button', body, action: { buttons } } }
}
const rows = itens.map((item, i) => ({ id: item.id, title: titulo(item, i, 24) }))
return {
to: para,
type: 'interactive',
interactive: { type: 'list', body, action: { button: 'Ver opções', sections: [{ rows }] } },
}
}Do WhatsApp para o Typebot
Texto vai como texto. Numa resposta de botão ou de lista, o evento do Hub traz o id e o título da opção em content.interactive. A ponte manda o título como texto e o id em metadata.replyId — é pelo id que o Typebot casa a escolha, do mesmo jeito que a integração nativa dele faz. Isso importa porque o título do botão é cortado em 20 caracteres no WhatsApp, e o texto cortado nem sempre bate com o item.
// O que o contato mandou, no formato da API de chat do Typebot. Numa
// resposta de botao ou lista, o id volta em metadata.replyId: e por ele que o
// Typebot sabe qual opcao foi escolhida, mesmo com o titulo cortado.
function paraTypebot(evento) {
const interativo = evento.data.content?.interactive
const escolha = interativo?.button_reply ?? interativo?.list_reply
if (escolha) return { type: 'text', text: escolha.title, metadata: { replyId: escolha.id } }
return { type: 'text', text: evento.data.text ?? '' }
}A ponte inteira
Juntando as duas conversões. O Idempotency-Key de cada envio sai do message_id da mensagem recebida e da posição da resposta: se a mesma entrega for processada de novo, a API devolve o resultado original em vez de mandar a mensagem duas vezes.
const TYPEBOT = 'https://typebot.io/api/v1' // self-hosted: a URL do viewer
const ID_PUBLICO = 'meu-bot-publicado' // aba Share do Typebot
const sessoes = new Map() // troque por Redis ou banco: chave canal:contato
async function aoReceber(evento) {
// Ja verificado (assinatura) e deduplicado pelo id do evento.
if (evento.event !== 'message.received') return
const chave = `${evento.channel_id}:${evento.data.from}`
const mensagem = paraTypebot(evento)
let resposta
const sessao = sessoes.get(chave)
if (sessao) {
resposta = await chamar(`/sessions/${sessao}/continueChat`, { message: mensagem, textBubbleContentFormat: 'markdown' })
}
if (!sessao || resposta.status === 404) {
// Sessao nova, ou expirada por inatividade (o Typebot responde 404): o
// fluxo recomeca do inicio, como uma conversa nova.
resposta = await chamar(`/typebots/${ID_PUBLICO}/startChat`, {
textBubbleContentFormat: 'markdown',
prefilledVariables: { telefone: evento.data.from, nome: evento.data.contact?.name ?? '' },
})
}
const corpo = await resposta.json()
if (corpo.sessionId) sessoes.set(chave, corpo.sessionId)
for (const [i, envio] of paraHub(corpo, evento.data.from).entries()) {
await fetch(`https://crprohub.com/api/v1/channels/${evento.channel_id}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CRPRO_API_KEY}`,
'Content-Type': 'application/json',
// Estavel por mensagem recebida e por posicao: reprocessar nao duplica.
'Idempotency-Key': `typebot-${evento.data.message_id}-${i}`,
},
body: JSON.stringify(envio),
})
}
}
function chamar(caminho, corpo) {
return fetch(`${TYPEBOT}${caminho}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(corpo),
})
}As variáveis telefone e nome só são preenchidas se existirem no seu bot; crie-as no Typebot para usar o número e o nome do contato no fluxo. Estes trechos são executados nos testes do CRPRO Hub, com respostas de exemplo do Typebot, e todo envio que eles produzem passa na validação da API de envio.
No n8n
O n8n não tem nó oficial do Typebot, então a ponte usa nós genéricos: o Webhook e o Code de verificação do guia de n8n, um HTTP Request para o Typebot, um Code com as duas conversões acima e um HTTP Request por envio para o Hub. A sessão de cada contato cabe numa tabela do próprio n8n ou num Redis.
O que não passa pela ponte
- Blocos que dependem da página web do Typebot — pagamento, upload de arquivo, vídeo embutido, scripts no navegador — não têm equivalente no WhatsApp. Mantenha o fluxo em texto e escolhas.
- A janela de 24 horas vale para o bot também: toda resposta logo depois de uma mensagem do contato está dentro dela, mas um bloco de espera longo pode tentar responder com a janela fechada e levar o erro 131047.
- Duas mensagens seguidas do mesmo contato podem chegar fora de ordem quando uma delas é retentada. Para bot, processar uma por vez por contato evita mandar a resposta errada para o bloco atual.