CRPRO HubEntrar no painel

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.received na ponte, que verifica a assinatura, deduplica pelo id do evento e responde 2xx logo.
  • Sem sessão para aquele contato, a ponte chama startChat. Com sessão, chama continueChat mandando 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 continueChat responde 404: a ponte abre uma sessão nova, e o fluxo recomeça do início, como numa conversa nova.
Assine só 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.

Typebot → CRPRO Hub
// 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.

CRPRO Hub → Typebot
// 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.

ponte.js
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.