Todo POST da Meta traz o header x-hub-signature-256, um HMAC SHA-256 do corpo bruto usando o App Secret. Você precisa validar isso sobre o body cru (não o JSON já parseado), com comparação em tempo constante. Sem essa verificação, qualquer um que descubra a URL pode injetar eventos falsos.
O segundo pilar é idempotência. A Meta pode reentregar o mesmo evento (timeout, retry interno). Cada mensagem traz um id único; registre-o em um store com TTL antes de enfileirar e descarte duplicatas. Assim o mesmo evento nunca dispara duas respostas ou duas escritas no CRM.
// server.js - webhook WhatsApp Cloud API (Node.js / Express)
const express = require('express');
const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const APP_SECRET = process.env.WHATSAPP_APP_SECRET;
const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN;
const app = express();
// IMPORTANTE: capturar o corpo BRUTO para validar o HMAC.
// O JSON re-serializado nao bate byte a byte com o assinado pela Meta.
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
// Comparacao em tempo constante para evitar timing attacks.
function isValidSignature(req) {
const header = req.get('x-hub-signature-256');
if (!header || !req.rawBody) return false;
const expected =
'sha256=' +
crypto.createHmac('sha256', APP_SECRET).update(req.rawBody).digest('hex');
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Idempotencia: registra o message id com TTL; retorna false se ja visto.
async function markIfNew(messageId) {
// SET key value NX EX 86400 -> grava so se nao existir, expira em 24h.
const result = await redis.set(`wa:msg:${messageId}`, '1', 'EX', 86400, 'NX');
return result === 'OK';
}
// Handshake de verificacao (GET) exigido pela Meta ao configurar a URL.
app.get('/webhook', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
// Recebimento de eventos (POST).
app.post('/webhook', async (req, res) => {
if (!isValidSignature(req)) {
return res.sendStatus(401);
}
// Responde rapido: a Meta reentrega se houver demora. Processamento e assincrono.
res.sendStatus(200);
try {
const entries = req.body.entry || [];
for (const entry of entries) {
for (const change of entry.changes || []) {
const messages = change.value?.messages || [];
for (const message of messages) {
const isNew = await markIfNew(message.id);
if (!isNew) continue; // duplicata: ignora
// Enfileira para o worker. O endpoint nao executa regra de negocio.
await redis.lpush(
'wa:incoming',
JSON.stringify({
messageId: message.id,
from: message.from,
type: message.type,
text: message.text?.body,
timestamp: message.timestamp,
})
);
}
}
}
} catch (err) {
// Ja respondemos 200; logamos e deixamos o reprocessamento a cargo do worker/DLQ.
console.error('webhook processing error', err);
}
});
app.listen(process.env.PORT || 3000);