CRPRO HubEntrar no painel

Como integrar a API do WhatsApp no n8n

Enviar mensagem no n8n é um nó HTTP Request com dois headers. Receber é um nó Webhook. A parte que ninguém conta é a do meio: validar a assinatura da entrega não tem nó pronto — precisa de um Code. Este guia mostra os três, e termina num fluxo que responde sozinho a quem manda mensagem.

O que você precisa

  • Uma chave de API (hub_pk_...) com o escopo messages:send, criada em /painel/chaves. Para criar o endpoint de webhook pela API, a chave também precisa de webhooks:write.
  • O id do canal, de GET /api/v1/channels.
  • Uma instância do n8n acessível pela internet, em HTTPS. O CRPRO Hub recusa URL de webhook que aponte para loopback, link-local ou faixa privada de IP — 400 WEBHOOK_URL_NOT_ALLOWED. Um n8n em localhost não serve como destino: exponha por túnel ou publique.
  • O segredo do webhook (whsec_...), devolvido uma única vez na criação do endpoint.

Enviar com o nó HTTP Request

Um nó só. A configuração inteira:

  • Method: POST.
  • URL: https://crprohub.com/api/v1/channels/{id}/messages, com o id do canal no lugar de {id}.
  • Send Headers ligado, com Authorization = Bearer hub_pk_... e Idempotency-Key — o valor deste segundo é o assunto da próxima seção.
  • Send Body ligado, Body Content Type em JSON, Specify Body em Using JSON.
Corpo JSON
{
  "to": "5521999999999",
  "type": "text",
  "text": { "body": "Olá" }
}

O to é o número do destinatário com código do país, só dígitos — sem +, sem espaço, sem parênteses. O type aceita text, image, audio, video, document, sticker, location, contacts, reaction, interactive e template, e cada um exige o objeto de mesmo nome ao lado. Não existe endpoint separado por tipo: é sempre este, mudando o type.

A resposta é 202, e isso não é detalhe: significa aceita e enfileirada para a Meta, não entregue. O nó HTTP Request trata 202 como sucesso, então o fluxo segue normalmente — mas um IF depois dele comparando o status com 200 nunca vai casar. O corpo vem envelopado em data: o identificador está em {{ $json.data.message_id }}.

Coloque a chave numa credencial Header Auth em vez de digitá-la no campo do nó. Parâmetro de nó viaja no JSON exportado do fluxo — que é o que as pessoas colam em fórum, commitam no Git e mandam por chat quando pedem ajuda. Credencial não sai na exportação. Vale o mesmo para o token do canal (hub_ch_...), que autentica este envio e só serve para o canal que o emitiu.

Ligue também Include Response Headers and Status nas opções do nó. É o que traz o x-request-id de volta, e é ele que liga a sua execução ao registro em /painel/logs quando algo dá errado.

A Idempotency-Key no n8n

O header Idempotency-Key é obrigatório neste endpoint. A regra é simples de enunciar e fácil de errar no n8n: o valor precisa ser o mesmo em toda tentativa do mesmo envio e diferente entre envios distintos.

Quando o envio nasce de um evento — uma mensagem que chegou, um pedido que mudou de estado — a melhor chave não vem do n8n, vem do próprio evento:

Idempotency-Key derivada do evento
resposta-{{ $json.evento.data.message_id }}

Essa chave é estável de verdade. Se a entrega do webhook for reenviada, se você reexecutar a execução à mão, ou se o fluxo rodar duas vezes por qualquer motivo, o valor é o mesmo — e a API devolve a resposta original em vez de mandar a mensagem de novo. Nenhuma expressão de runtime do n8n te dá essa garantia.

Quando não há evento de origem — um disparo agendado, uma planilha, um formulário — use a execução:

Idempotency-Key por execução
crpro-{{ $execution.id }}-{{ $itemIndex }}

Três coisas nessa expressão são deliberadas. $execution.id é único por execução e não muda quando o Retry On Fail do próprio nó tenta de novo — que é exatamente o comportamento que a idempotência precisa. $itemIndex separa os itens: sem ele, um nó que processa dez contatos manda os dez com a mesma chave e corpos diferentes, e do segundo em diante a resposta é 409 IDEMPOTENCY_KEY_REUSED. E o prefixo existe porque um id de execução novinho pode ter três ou quatro caracteres — curto demais, e a API responde 400 IDEMPOTENCY_KEY_INVALID.

Se você precisa mesmo de um UUID aleatório, o nó Crypto com a ação Generate e tipo UUID gera um por item, num campo que você nomeia — depois referencie esse campo no header. Só entenda o que muda: aleatório por item sobrevive à retentativa do nó HTTP Request, mas não sobrevive a uma reexecução do fluxo inteiro, porque o Crypto roda de novo e gera outro. Chave derivada do dado é sempre mais forte que chave gerada na hora.

Reexecutar com a mesma chave enquanto a primeira ainda está em curso responde 409 OPERATION_IN_PROGRESS — espere e repita, a resposta original volta quando a operação terminar. A tabela completa está em erros.

Receber com o nó Webhook

Do lado do recebimento, o nó Webhook do n8n é o destino. Configure HTTP Method em POST, escolha um Path, e ligue duas opções que decidem se a integração vai funcionar:

  • Raw Body — sem isso o n8n faz parse do JSON e entrega um objeto, jogando os bytes originais fora. A assinatura cobre esses bytes exatos, então JSON.stringify do objeto produz outro HMAC e a verificação falha em 100% das entregas, sem nenhuma pista de que a causa é o parser.
  • Respond em Using 'Respond to Webhook' Node. O endpoint tem 10 segundos para responder 2xx; salvar no banco, chamar uma IA e mandar a resposta não cabe nesse orçamento. Com um nó Respond to Webhook logo depois da verificação, você responde rápido e continua processando no mesmo fluxo.

O n8n dá duas URLs para o mesmo nó: a de teste e a de produção. Registre a de produção — a URL de teste só escuta enquanto você mantém o botão de escuta ativo no editor, e uma entrega que chega depois disso falha. Uma entrega que falha é reenviada até nove vezes, somando dez tentativas, e depois de 20 falhas consecutivas o endpoint pausa sozinho.

Criar o endpoint
curl -X POST https://crprohub.com/api/v1/webhooks \
  -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL" \
  -H "Content-Type: application/json" \
  -d '{"name":"n8n","url":"https://SEU-N8N/webhook/crprohub","event_types":["message.received","message.status"]}'

O event_types nasce vazio e endpoint sem evento assinado recebe todos os eventos, não nenhum. Os dois que interessam num fluxo de atendimento são message.received e message.status. O secret (whsec_...) vem só nesta resposta e não é reemitido. Você também pode criar o endpoint pelo painel — o passo a passo está em configurar webhook.

Verificar a assinatura exige um Code

Aqui acaba o “sem código”, e é melhor dizer isso na cara: não existe nó do n8n que valide a assinatura HMAC das entregas. Ou você escreve um Code, ou o seu webhook aceita qualquer POST que alguém mandar para aquela URL — e a URL de um nó Webhook é pública.

Toda entrega chega com X-Hub-Delivery-Id, X-Hub-Timestamp, X-Hub-Signature-Version e X-Hub-Signature-256, este último no formato sha256=HEX. No formato nativo (v2, o padrão) a assinatura não cobre só o corpo: cobre `${timestamp}.${delivery_id}.${corpo}`. Amarrar os três é o que impede transplantar um corpo capturado para outra entrega — e assinar só o corpo é o erro que mais derruba integração nova.

Code: verificar assinatura
const { createHmac, timingSafeEqual } = require('crypto')

const entrada = $input.first()
const headers = entrada.json.headers

// A opcao Raw Body do no Webhook precisa estar ligada. Sem ela o n8n entrega
// o corpo ja convertido em objeto, e reserializar esse objeto nao devolve os
// mesmos bytes -- espacamento, ordem das chaves e escapes de Unicode mudam.
const rawBody = Buffer.from(entrada.binary.data.data, 'base64').toString('utf8')

const timestamp = headers['x-hub-timestamp']
const deliveryId = headers['x-hub-delivery-id']
const assinado = `${timestamp}.${deliveryId}.${rawBody}`

const esperada = createHmac('sha256', $env.CRPRO_WEBHOOK_SECRET).update(assinado).digest('hex')
const recebida = (headers['x-hub-signature-256'] ?? '').replace('sha256=', '')

const valido =
  /^[0-9a-f]{64}$/.test(recebida) &&
  timingSafeEqual(Buffer.from(recebida, 'hex'), Buffer.from(esperada, 'hex'))

return [{ json: { valido, deliveryId, evento: JSON.parse(rawBody) } }]

Duas linhas desse trecho não são enfeite:

  • timingSafeEqual, nunca ===. Comparação de string comum para no primeiro byte diferente, e esse tempo vaza quantos bytes o atacante já acertou — dá para descobrir a assinatura correta por tentativa e erro.
  • O teste /^[0-9a-f]{64}$/ antes do Buffer.from. Um valor de 64 caracteres não hexadecimais vira um buffer mais curto, e timingSafeEqual lança RangeError em vez de devolver false — o nó falha, o n8n devolve erro, e a entrega volta na retentativa.
Em n8n self-hosted, o Code node só importa módulo nativo se NODE_FUNCTION_ALLOW_BUILTIN permitir — inclua crypto, escrito exatamente assim: a allowlist casa pelo especificador literal, então require('node:crypto') não resolve mesmo com crypto liberado. No n8n Cloud essa variável não é configurável, e o caminho não existe. Ler o segredo com $env depende de o acesso a variáveis de ambiente não estar bloqueado na instância. Se a sua não permite nenhum dos dois, você não tem como verificar a assinatura em tempo constante dentro do n8n: ponha um serviço seu na frente e mande para o n8n só o que já foi validado.

Leia o X-Hub-Signature-Version antes de suspeitar do segredo. Um endpoint criado com delivery_format: "evohub" manda evohub-v1 e assina somente o corpo cru — cálculo diferente, mesmo sintoma. O trecho desse caso está em webhooks; em integração nova, prefira native.

O fluxo de resposta automática

Mensagem chega, o fluxo responde. Seis nós, nesta ordem:

  • Webhook — POST, Raw Body ligado, Respond em Using 'Respond to Webhook' Node.
  • Code — o trecho da seção anterior. Devolve valido, deliveryId e evento.
  • IF — {{ $json.valido }} é verdadeiro. O ramo falso vai direto para um Respond to Webhook com status 401.
  • Respond to Webhook — status 200, sem corpo. Fica antes do resto de propósito: o relógio de 10 segundos para aqui, e o que vem depois pode demorar o que precisar.
  • Remove Duplicates — no modo que lembra valores de execuções anteriores, com {{ $json.deliveryId }} como chave. Uma retentativa reenvia o mesmo X-Hub-Delivery-Id; sem esse nó, o contato recebe a mesma resposta de novo a cada reenvio.
  • HTTP Request — o envio, com o corpo abaixo.
Corpo JSON da resposta
{
  "to": "{{ $json.evento.data.from }}",
  "type": "text",
  "text": { "body": "Recebemos sua mensagem. Já respondemos por aqui." }
}

Para as expressões funcionarem dentro do campo JSON, o campo inteiro precisa estar em modo expressão — o botão de alternância ao lado dele. Sem isso o n8n manda {{ $json.evento.data.from }} como texto literal, e o envio volta 400 INVALID_MESSAGE.

O Idempotency-Key deste nó é a expressão derivada do evento, lá de cima: resposta-{{ $json.evento.data.message_id }}. Junto com o Remove Duplicates, são duas camadas contra o mesmo problema — o nó protege dentro do seu n8n, a chave protege na API, e uma cobre a falha da outra.

Antes de mandar a resposta, vale filtrar por {{ $json.evento.event }} igual a message.received: se o endpoint também assina message.status, todo evento de status cairia no mesmo caminho e viraria uma resposta ao contato. O payload completo de message.received está em receber mensagens.

Texto livre só sai se o contato falou com você nas últimas 24 horas. Numa resposta automática disparada por message.received a janela está sempre aberta — mas o mesmo nó HTTP Request usado num disparo agendado precisa de type: "template". Veja enviar template.

Onde as pessoas erram

  • Raw Body desligado. Sintoma: valido falso em toda entrega, incluindo o webhook.test, com o segredo certo. É o primeiro lugar para olhar.
  • URL de teste registrada como endpoint. Funciona enquanto o editor está escutando e para de funcionar quando você fecha a aba. Vinte falhas seguidas pausam o endpoint, e aí nem a URL certa recebe até você reativar no painel.
  • n8n em rede privada. http://localhost:5678 e IPs internos são recusados na criação com 400 WEBHOOK_URL_NOT_ALLOWED. A entrega também não segue redirect: a URL cadastrada precisa ser o destino final.
  • IF esperando 200. O envio responde 202, sempre. Um IF comparando com 200 manda todo envio bem-sucedido para o ramo de erro.
  • Mesma chave para itens diferentes. Um nó HTTP Request que roda sobre uma lista sem $itemIndex na chave manda o primeiro item e recebe 409 IDEMPOTENCY_KEY_REUSED em todos os outros.
  • Esperar 403 e receber 404. Um canal de outra organização responde 404, não 403: 403 confirmaria que o recurso existe. Se o id parece certo e volta 404, confira a organização da chave antes do id.
  • Header com maiúsculas no Code. Os nomes chegam em minúsculas: headers['x-hub-signature-256'] funciona, headers['X-Hub-Signature-256'] devolve undefined e a verificação falha sem erro aparente.

Os limites de taxa estão em limites — o envio tem um limite próprio por canal, e um fluxo do n8n que dispara em laço encosta nele rápido. O contrato dos endpoints está em referência de mensagens.