Verificar a assinatura
Toda entrega vai assinada. Validar a assinatura é o que impede alguém de forjar mensagens para o seu endpoint — a URL é pública, então sem validação qualquer um pode postar nela.
O algoritmo
assinatura = HMAC-SHA256(segredo, "{timestamp}.{corpo bruto}")
Em hexadecimal, prefixado por sha256=.
| Componente | Origem |
|---|---|
segredo | O whsec_... do endpoint |
timestamp | Header X-Joinotify-Timestamp, Unix em segundos |
corpo bruto | Os bytes do corpo, antes de qualquer parse |
O timestamp entra no HMAC de propósito: sem ele, alguém que interceptasse uma entrega válida poderia reenviá-la indefinidamente. Compare-o com o relógio atual e recuse o que for velho demais — 300 segundos é a tolerância recomendada.
Node.js + Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const TOLERANCIA_SEGUNDOS = 300;
// A rota do webhook precisa do corpo BRUTO. Registre-a ANTES de qualquer
// middleware de JSON — express.json() reserializa o payload e a assinatura
// deixa de bater.
app.post('/webhooks/joinotify', express.raw({ type: 'application/json' }), (req, res) => {
const assinatura = req.get('X-Joinotify-Signature-256') ?? '';
const timestamp = req.get('X-Joinotify-Timestamp') ?? '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCIA_SEGUNDOS) {
return res.sendStatus(401);
}
const esperado =
'sha256=' +
crypto.createHmac('sha256', process.env.JOINOTIFY_WEBHOOK_SECRET).update(`${timestamp}.${req.body}`).digest('hex');
const a = Buffer.from(assinatura);
const b = Buffer.from(esperado);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
res.sendStatus(200);
processarEmBackground(JSON.parse(req.body.toString()));
});
app.use(express.json()); // demais rotas
Python + Flask
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
TOLERANCIA_SEGUNDOS = 300
SEGREDO = os.environ["JOINOTIFY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/joinotify")
def webhook():
assinatura = request.headers.get("X-Joinotify-Signature-256", "")
timestamp = request.headers.get("X-Joinotify-Timestamp", "0")
if abs(time.time() - int(timestamp)) > TOLERANCIA_SEGUNDOS:
return "", 401
# request.get_data() devolve os bytes brutos; request.json faria o parse.
corpo = request.get_data()
esperado = "sha256=" + hmac.new(SEGREDO, f"{timestamp}.".encode() + corpo, hashlib.sha256).hexdigest()
if not hmac.compare_digest(assinatura, esperado):
return "", 401
enfileirar(request.get_json())
return "", 200
PHP
<?php
$tolerancia = 300;
$segredo = getenv('JOINOTIFY_WEBHOOK_SECRET');
$assinatura = $_SERVER['HTTP_X_JOINOTIFY_SIGNATURE_256'] ?? '';
$timestamp = $_SERVER['HTTP_X_JOINOTIFY_TIMESTAMP'] ?? '0';
$corpo = file_get_contents('php://input');
if (abs(time() - (int) $timestamp) > $tolerancia) {
http_response_code(401);
exit;
}
$esperado = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $corpo, $segredo);
if (!hash_equals($esperado, $assinatura)) {
http_response_code(401);
exit;
}
http_response_code(200);
fastcgi_finish_request(); // responde antes de processar
enfileirar(json_decode($corpo, true));
Por que a assinatura não bate
Quase sempre é uma destas quatro.
O corpo foi parseado antes. express.json(), bodyParser, request.json — todos
reserializam e mudam espaços e ordem de chaves. Use os bytes brutos.
O timestamp ficou de fora. O HMAC é sobre "{timestamp}.{corpo}", não sobre o corpo
sozinho. Este é o erro mais comum de quem vem da Meta, onde a assinatura é só do corpo.
O prefixo sha256= foi esquecido. Ele faz parte do valor comparado.
Um proxy alterou o corpo. Alguns reverse proxies reescrevem o JSON. Se você está atrás de um, confirme que ele repassa o corpo intacto.
Sempre com comparação em tempo constante
crypto.timingSafeEqual, hmac.compare_digest, hash_equals. Nunca ==.
Comparação comum sai no primeiro byte diferente, e essa diferença de tempo é mensurável — dá para descobrir a assinatura correta byte a byte. As funções acima levam o mesmo tempo independentemente de onde está a diferença.
Testando
curl -X POST https://api.joinotify.com/webhook-endpoints/whe_01J8ZK.../test \
-H 'Authorization: Bearer sk_live_xxx'
responseStatus: 200 na resposta significa que a sua validação passou. 401 significa
que ela está recusando entregas legítimas — reveja a lista acima.