Conferir a assinatura
Como o seu sistema sabe que um webhook veio mesmo da OTL e não foi alterado no caminho.
O seu endereço de webhook é público: qualquer um que o descubra pode mandar um POST para ele. A
assinatura é o que separa um aviso da OTL de uma requisição forjada. Não processe um webhook sem
conferi-la.
Como funciona
Cada endereço tem um segredo (otl_whsec_…), mostrado uma única vez no painel quando o
webhook é cadastrado. Toda entrega traz o cabeçalho:
X-OTL-Assinatura: t=1790701380,v1=5f3c…a91e
té o instante do envio, em segundos (Unix).v1é oHMAC-SHA256, em hexadecimal, do textot+.+ corpo cru da requisição, usando o segredo como chave.
Para conferir:
- Separe
te os valoresv1do cabeçalho. - Calcule
HMAC-SHA256(segredo, t + "." + corpo). - Compare com o
v1recebido, com uma comparação de tempo constante. - Recuse se
ttiver mais de 5 minutos — é o que impede alguém de reenviar uma entrega antiga que tenha capturado.
A conta é feita sobre os bytes exatos que chegaram. Se o seu framework já transformou o JSON em objeto e você o serializar de novo, a ordem das chaves ou os espaços podem mudar, e a assinatura não confere. Leia o corpo como texto antes de interpretá-lo.
Exemplos
Node.js
import crypto from 'node:crypto';
export function assinaturaValida(corpoCru, cabecalho, segredo) {
const partes = String(cabecalho ?? '').split(',');
const t = partes.find(p => p.startsWith('t='))?.slice(2);
const recebidas = partes.filter(p => p.startsWith('v1=')).map(p => p.slice(3));
if (!t || !recebidas.length) return false;
// Entrega antiga: recusa.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const esperada = crypto.createHmac('sha256', segredo).update(`${t}.${corpoCru}`).digest('hex');
return recebidas.some(
v => v.length === esperada.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(esperada)),
);
}
// Express: o corpo precisa chegar CRU nesta rota.
app.post('/webhook/otl', express.raw({ type: 'application/json' }), (req, res) => {
const corpo = req.body.toString('utf8');
if (!assinaturaValida(corpo, req.get('X-OTL-Assinatura'), process.env.OTL_WEBHOOK_SEGREDO)) {
return res.sendStatus(401);
}
res.sendStatus(200); // responde primeiro
fila.adicionar(JSON.parse(corpo)); // processa depois
});
PHP
<?php
$corpo = file_get_contents('php://input'); // corpo cru
$cabecalho = $_SERVER['HTTP_X_OTL_ASSINATURA'] ?? '';
$segredo = getenv('OTL_WEBHOOK_SEGREDO');
$t = null;
$recebidas = [];
foreach (explode(',', $cabecalho) as $parte) {
if (str_starts_with($parte, 't=')) $t = substr($parte, 2);
if (str_starts_with($parte, 'v1=')) $recebidas[] = substr($parte, 3);
}
$valida = false;
if ($t !== null && abs(time() - (int) $t) <= 300) {
$esperada = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
foreach ($recebidas as $v) {
if (hash_equals($esperada, $v)) $valida = true;
}
}
if (!$valida) {
http_response_code(401);
exit;
}
http_response_code(200);
$evento = json_decode($corpo, true);
// guarde o evento e processe depois
Python
import hashlib, hmac, os, time
def assinatura_valida(corpo_cru: bytes, cabecalho: str, segredo: str) -> bool:
partes = (cabecalho or "").split(",")
t = next((p[2:] for p in partes if p.startswith("t=")), None)
recebidas = [p[3:] for p in partes if p.startswith("v1=")]
if not t or not recebidas:
return False
if abs(time.time() - int(t)) > 300: # entrega antiga
return False
esperada = hmac.new(segredo.encode(), f"{t}.".encode() + corpo_cru, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperada, v) for v in recebidas)
# Flask
@app.post("/webhook/otl")
def webhook_otl():
corpo = request.get_data() # bytes crus
if not assinatura_valida(corpo, request.headers.get("X-OTL-Assinatura"), os.environ["OTL_WEBHOOK_SEGREDO"]):
return "", 401
fila.adicionar(corpo) # processa depois
return "", 200
n8n
No nó Webhook, em Options, ligue Raw Body — sem isso o n8n entrega o JSON já
interpretado e a conta não fecha. Depois, um nó Code como o abaixo. Trate-o como ponto de
partida: o nome do campo com o corpo cru varia com a versão do n8n, e em instalações próprias o
módulo crypto precisa estar liberado (NODE_FUNCTION_ALLOW_BUILTIN=crypto). A lógica é a mesma
dos outros exemplos.
const crypto = require('crypto');
const item = $input.first();
const corpo = Buffer.from(item.binary.data.data, 'base64').toString('utf8');
const cabecalho = item.json.headers['x-otl-assinatura'] ?? '';
const segredo = $env.OTL_WEBHOOK_SEGREDO;
const partes = cabecalho.split(',');
const t = partes.find(p => p.startsWith('t='))?.slice(2);
const recebidas = partes.filter(p => p.startsWith('v1=')).map(p => p.slice(3));
const esperada = crypto.createHmac('sha256', segredo).update(`${t}.${corpo}`).digest('hex');
const recente = t && Math.abs(Date.now() / 1000 - Number(t)) <= 300;
if (!recente || !recebidas.includes(esperada)) {
throw new Error('Assinatura do webhook OTL inválida');
}
return [{ json: JSON.parse(corpo) }];
Guarde o segredo numa variável de ambiente do n8n (ou numa credencial), nunca dentro do fluxo.
Trocar o segredo
No painel, Novo segredo gera outro e mostra uma vez. Para a troca não perder avisos, durante 24 horas as entregas saem assinadas com os dois segredos:
X-OTL-Assinatura: t=1790701380,v1=<assinatura com o novo>,v1=<assinatura com o anterior>
Por isso os exemplos acima aceitam a entrega quando qualquer v1 confere. Atualize o segredo
no seu sistema dentro dessas 24 horas; depois disso só o novo assina.
Erros comuns
| Sintoma | Causa provável |
|---|---|
| A assinatura nunca confere | O corpo foi interpretado e serializado de novo. Use o corpo cru |
| Confere no teste e falha em produção | O segredo é por endereço: cada webhook cadastrado tem o seu |
| Passou a falhar depois de um tempo | O relógio do servidor está errado, e a entrega parece ter mais de 5 minutos |
| Falha só depois de “Novo segredo” | Passaram as 24 horas e o sistema ainda usa o segredo anterior |
