CRPRO HubEntrar no painel

Como integrar a API do WhatsApp em Node.js

Uma integração completa em Node.js tem duas metades: a chamada que envia e o servidor que recebe. A primeira é curta. A segunda tem uma armadilha que derruba a integração inteira, e ela está no seu framework, não na API.

O que você precisa

  • Node 18 ou mais novo. fetch, crypto.randomUUID e timingSafeEqual são nativos — não há dependência para instalar no caminho principal.
  • Uma chave de API (hub_pk_...) com o escopo messages:send, criada em /painel/chaves. Ela aparece uma vez só; guarde em variável de ambiente, nunca no repositório.
  • O id do canal, de GET /api/v1/channels.
  • O segredo do webhook (whsec_...), devolvido uma única vez por POST /api/v1/webhooks.

Enviar com fetch nativo

O envio é um POST para /api/v1/channels/{id}/messages, com três headers e um corpo de dois níveis: to, type, e o objeto do tipo escolhido.

enviar.js
const BASE = 'https://crprohub.com/api/v1'

export async function enviarTexto({ canalId, para, texto, chave }) {
  const resposta = await fetch(`${BASE}/channels/${canalId}/messages`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CRPRO_API_KEY}`,
      'Idempotency-Key': chave,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ to: para, type: 'text', text: { body: texto } }),
  })

  const corpo = await resposta.json()

  if (resposta.status !== 202) {
    const erro = new Error(corpo.error?.message ?? 'Falha no envio')
    erro.codigo = corpo.error?.code
    erro.status = resposta.status
    erro.requestId = resposta.headers.get('x-request-id')
    throw erro
  }

  return corpo.data.message_id
}

Repare no !== 202. Este endpoint não devolve 200: o 202 diz que a mensagem foi aceita e enfileirada para a Meta — não que chegou. Tratar essa resposta como entrega confirmada é o erro mais comum de quem integra WhatsApp pela primeira vez. O estado real chega depois, por webhook.

O type aceita text, image, audio, video, document, sticker, location, contacts, reaction, interactive e template. Cada um exige o objeto de mesmo nome no corpo.

Guardar x-request-id no objeto de erro parece detalhe, mas é o que liga a sua stack trace ao registro em /painel/logs. Sem ele, investigar uma falha intermitente vira arqueologia por horário.

A chave na retentativa

O header Idempotency-Key é obrigatório, e por isso a função acima recebe a chave por parâmetro em vez de gerar uma dentro. A diferença decide se a sua retentativa protege ou duplica:

uso.js
import { randomUUID } from 'node:crypto'
import { enviarTexto } from './enviar.js'

// A chave nasce FORA do laco. Gerar uma por tentativa faria cada retentativa
// virar um envio novo -- exatamente o que a idempotencia existe para evitar.
const chave = randomUUID()

for (let tentativa = 1; tentativa <= 3; tentativa++) {
  try {
    const messageId = await enviarTexto({
      canalId: process.env.CRPRO_CHANNEL_ID,
      para: '5521999999999',
      texto: 'Olá',
      chave,
    })
    console.log(messageId)
    break
  } catch (erro) {
    // 4xx nao melhora com retentativa, com uma excecao: 429.
    // 409 OPERATION_IN_PROGRESS e a resposta a um retry cuja chave ainda
    // esta em curso: repetir e o comportamento certo, nao abortar.
    const vaiRepetir = erro.status >= 500 || erro.status === 429 || erro.status === 409
    if (!vaiRepetir) throw erro
    if (tentativa === 3) throw erro
    await new Promise((resolva) => setTimeout(resolva, 2 ** tentativa * 500))
  }
}

Com a mesma chave e o mesmo corpo, a segunda chamada devolve a resposta da primeira em vez de enviar de novo — é o que torna seguro repetir depois de um timeout, quando você não sabe se a requisição chegou. Um randomUUID() dentro do laço destruiria essa garantia em silêncio: o cliente receberia a mesma mensagem três vezes e nenhum log acusaria erro.

Reutilizar a mesma chave com um corpo diferente responde 409 IDEMPOTENCY_KEY_REUSED. Não trate isso como falha transitória — é a API avisando que o seu código reaproveitou uma chave que já pertence a outro envio.

Receber o webhook: o corpo cru

Aqui está a armadilha. A assinatura cobre os bytes exatos que chegaram na conexão. express.json() lê esses bytes, devolve um objeto e descarta o original — e JSON.stringify do objeto não reproduz os mesmos bytes, porque espaçamento, ordem de chaves e escapes de Unicode mudam. O resultado é uma assinatura inválida em 100% das entregas, sem nenhuma pista de que a causa é o parser.

servidor-express.js
import express from 'express'
import { assinaturaValida } from './assinatura.js'
import { enfileirar } from './fila.js'

const app = express()

// Nada de express.json() nesta rota, nem antes dela: o parser le o corpo,
// devolve um objeto e joga os bytes originais fora. A assinatura cobre os
// bytes exatos que chegaram, entao reserializar o JSON produz outro HMAC.
app.post('/webhooks/crprohub', express.raw({ type: 'application/json' }), (request, response) => {
  const rawBody = request.body.toString('utf8')

  if (!assinaturaValida(request, rawBody)) {
    response.status(401).end()
    return
  }

  enfileirar({
    deliveryId: request.headers['x-hub-delivery-id'],
    evento: JSON.parse(rawBody),
  })

  response.status(200).end()
})

app.listen(3000)

Se o resto da sua aplicação usa express.json(), registre-o depois desta rota ou monte o webhook num app separado. Um app.use(express.json()) no topo do arquivo já basta para quebrar tudo.

Sem Express, o mesmo com node:http:

servidor-http.js
import { createServer } from 'node:http'
import { assinaturaValida } from './assinatura.js'
import { enfileirar } from './fila.js'

createServer((request, response) => {
  const pedacos = []
  request.on('data', (pedaco) => pedacos.push(pedaco))
  request.on('end', () => {
    const rawBody = Buffer.concat(pedacos).toString('utf8')

    if (!assinaturaValida(request, rawBody)) {
      response.writeHead(401).end()
      return
    }

    enfileirar({
      deliveryId: request.headers['x-hub-delivery-id'],
      evento: JSON.parse(rawBody),
    })

    response.writeHead(200).end()
  })
}).listen(3000)

Verificar a assinatura

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. O header de versão decide o cálculo: v2 é o formato nativo, evohub-v1 é o de compatibilidade EvoHub.

No formato nativo, o HMAC não cobre só o corpo: cobre `${timestamp}.${delivery_id}.${corpo}`. Amarrar a assinatura aos três é o que impede transplantar um corpo capturado para outra entrega. Assinar só o corpo neste formato falha sempre — e é o segundo erro mais caro deste guia.

assinatura.js
import { createHmac, timingSafeEqual } from 'node:crypto'
const timestamp = request.headers['x-hub-timestamp']
const deliveryId = request.headers['x-hub-delivery-id']
const signed = `${timestamp}.${deliveryId}.${rawBody}`
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET).update(signed).digest('hex')
const received = request.headers['x-hub-signature-256']?.replace('sha256=', '')
const valid = !!received &&
  /^[0-9a-f]{64}$/.test(received) &&
  timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))

Coloque esse trecho dentro de export function assinaturaValida(request, rawBody) e devolva valid. Duas linhas dele não são opcionais:

  • timingSafeEqual, nunca ===. Comparação de string comum termina 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 produz um buffer mais curto, e timingSafeEqual lança RangeError em vez de devolver false — sua rota responde 500 e a entrega volta na retentativa.
Um endpoint criado com delivery_format: "evohub" assina somente o corpo cru. O trecho para esse caso está em webhooks. Em integração nova, prefira native.

Responder rápido, processar depois

O handler tem 10 segundos para responder 2xx. Salvar no banco, chamar uma IA e devolver uma resposta ao contato não cabe nesse orçamento — e estourar não é só lentidão: a entrega é reenviada, o seu código roda de novo, e o contato recebe a mesma resposta duas vezes.

fila.js
// Em producao, este Set vira uma tabela ou uma chave no Redis: um processo
// que reinicia esquece o que ja viu e reprocessa a entrega repetida.
const vistos = new Set()

export function enfileirar({ deliveryId, evento }) {
  if (vistos.has(deliveryId)) return
  vistos.add(deliveryId)

  fila.add('crprohub', { evento })
}

// No worker, fora do processo que responde ao HTTP.
fila.process(async ({ data }) => {
  const { event, data: dados } = data.evento

  if (event === 'message.received') await responderContato(dados)
  if (event === 'message.status') await atualizarStatus(dados)
})

A deduplicação por X-Hub-Delivery-Id é o que fecha essa porta: uma retentativa reenvia o mesmo id. Uma entrega que falha é reenviada até nove vezes, somando dez tentativas, e depois de 20 falhas consecutivas o endpoint pausa sozinho — reative no painel depois de corrigir a causa.

No envelope normalized, o corpo traz id, event, channel_id, occurred_at e data. Os eventos de mensagem são message.received e message.status; correlacione o status com o message_id que o envio devolveu.

Onde as pessoas erram

  • Parser de JSON antes da verificação. Vale para express.json(), body-parser, e para qualquer framework que entregue req.body já convertido. Sintoma: 401 em toda entrega, incluindo o webhook.test.
  • Assinar só o corpo num endpoint v2. Mesmo sintoma do item acima, causa diferente — por isso vale imprimir o X-Hub-Signature-Version antes de suspeitar do segredo.
  • Header em maiúsculas. Node normaliza os nomes para minúsculas: request.headers['x-hub-signature-256'] funciona, request.headers['X-Hub-Signature-256'] devolve undefined.
  • Proxy que reescreve o corpo. Alguns gateways e serverless recomprimem ou reformatam JSON antes de chegar no seu handler. Se a verificação passa local e falha em produção, é o primeiro lugar para olhar.
  • 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.

A tabela completa de códigos está em erros, os limites de taxa em limites, e o contrato dos endpoints em referência de mensagens.