Assinatura e segurança
Verifique que a requisição realmente veio da Vensy, com Node, Python ou o nó Code do n8n.
Toda entrega carrega três headers do padrão Standard Webhooks:
webhook-id: evt_01j...
webhook-timestamp: 1738425600
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=webhook-id: o id do evento. É o mesmo em todas as tentativas e em todos os endpoints — use-o para deduplicar.webhook-timestamp: segundos desde epoch, no momento em que a entrega foi assinada (muda a cada tentativa).webhook-signature:v1,seguido da assinatura em base64.
Como a assinatura é calculada
O conteúdo assinado é a concatenação {webhook-id}.{webhook-timestamp}.{corpo bruto da requisição} — exatamente os bytes enviados, sem reformatar o JSON. A chave é o segredo do endpoint (whsec_..., mostrado uma única vez na criação) decodificado de base64 sem o prefixo whsec_:
assinatura = "v1," + base64(HMAC-SHA256(base64decode(segredo sem "whsec_"), "{id}.{timestamp}.{corpo}"))Compare a assinatura inteira em tempo constante
Use uma função de comparação resistente a timing attack (crypto.timingSafeEqual em Node, hmac.compare_digest em Python). Comparar strings com ===/== vaza o tempo de execução e permite descobrir o segredo byte a byte.
Verificação em Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyVensyWebhook({ secret, headers, rawBody, toleranceSeconds = 300 }) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const received = headers["webhook-signature"];
if (!id || !timestamp || !received) {
throw new Error("headers de assinatura ausentes");
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error("timestamp fora da tolerância (possível replay)");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signedContent = `${id}.${timestamp}.${rawBody}`;
const expectedSignature = createHmac("sha256", key)
.update(signedContent, "utf8")
.digest("base64");
const expectedHeader = `v1,${expectedSignature}`;
const expectedBuf = Buffer.from(expectedHeader);
const receivedBuf = Buffer.from(received);
const valid =
expectedBuf.length === receivedBuf.length &&
timingSafeEqual(expectedBuf, receivedBuf);
if (!valid) throw new Error("assinatura inválida");
}Use rawBody — o corpo bruto da requisição, antes de qualquer JSON.parse. Em frameworks que já fazem parse automático (Express com express.json(), por exemplo), configure um verify callback para capturar os bytes originais antes do parse.
Verificação em Python
import hashlib
import hmac
import base64
import time
def verify_vensy_webhook(secret: str, headers: dict, raw_body: bytes, tolerance_seconds: int = 300) -> None:
event_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
received = headers["webhook-signature"]
if abs(time.time() - int(timestamp)) > tolerance_seconds:
raise ValueError("timestamp fora da tolerância (possível replay)")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{event_id}.{timestamp}.".encode() + raw_body
expected_signature = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode()
expected_header = f"v1,{expected_signature}"
if not hmac.compare_digest(expected_header, received):
raise ValueError("assinatura inválida")Verificação no nó Code do n8n
Quando não dá para validar fora do n8n, um nó Code logo após o Webhook (antes de qualquer lógica de negócio) resolve:
const crypto = require('crypto');
const SECRET = $env.VENSY_WEBHOOK_SECRET; // configure como variável de ambiente do n8n
const headers = $input.first().json.headers;
const rawBody = $input.first().json.body; // ver nota abaixo sobre corpo bruto
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const received = headers['webhook-signature'];
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
throw new Error('timestamp fora da tolerância');
}
const key = Buffer.from(SECRET.replace(/^whsec_/, ''), 'base64');
const signedContent = `${id}.${timestamp}.${JSON.stringify(rawBody)}`;
const expected = 'v1,' + crypto.createHmac('sha256', key).update(signedContent, 'utf8').digest('base64');
const expectedBuf = Buffer.from(expected);
const receivedBuf = Buffer.from(received);
const valid = expectedBuf.length === receivedBuf.length && crypto.timingSafeEqual(expectedBuf, receivedBuf);
if (!valid) {
throw new Error('assinatura inválida');
}
return $input.all();O n8n reserializa o JSON por padrão
O nó Webhook do n8n faz o parse do corpo antes de você conseguir os bytes originais, e JSON.stringify não reproduz byte a byte o corpo que a Vensy assinou (ordem de chaves, espaçamento). Para verificação 100% fiel, prefira validar num backend seu antes de repassar ao n8n, ou habilite "Raw Body" nas opções do nó Webhook (quando disponível na sua versão) para ter acesso ao corpo bruto.
Proteção contra replay
Rejeite qualquer entrega cujo webhook-timestamp esteja fora de uma tolerância razoável (5 minutos é um bom padrão) em relação ao relógio do seu servidor. Isso impede que alguém capture uma entrega antiga, válida na época, e a reenvie mais tarde.
Regenerar o segredo
Regenerar o segredo de um endpoint invalida o anterior imediatamente — não há período de convivência entre os dois segredos. Depois de regenerar, atualize a verificação do lado da sua integração antes da próxima entrega, ou todas as assinaturas passam a falhar até você sincronizar o novo segredo.
Links de mídia expiram em 7 dias
Mensagens com mídia (imagem, áudio, documento...) trazem media.url apontando para um link assinado da Vensy, que redireciona para a URL pré-assinada do storage. Esse link funciona por até 7 dias a partir do evento — o mesmo prazo de retenção do corpo da entrega. Baixe o arquivo e guarde-o no seu lado se precisar dele depois disso.