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.randomUUIDetimingSafeEqualsão nativos — não há dependência para instalar no caminho principal. - Uma chave de API (
hub_pk_...) com o escopomessages: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 porPOST /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.
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.
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:
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.
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.
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:
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.
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 doBuffer.from. Um valor de 64 caracteres não hexadecimais produz um buffer mais curto, etimingSafeEquallançaRangeErrorem vez de devolverfalse— sua rota responde 500 e a entrega volta na retentativa.
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.
// 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 entreguereq.bodyjá convertido. Sintoma: 401 em toda entrega, incluindo owebhook.test. - Assinar só o corpo num endpoint
v2. Mesmo sintoma do item acima, causa diferente — por isso vale imprimir oX-Hub-Signature-Versionantes 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']devolveundefined. - 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ão403: 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.