Pular para o conteúdo principal

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=.

ComponenteOrigem
segredoO whsec_... do endpoint
timestampHeader X-Joinotify-Timestamp, Unix em segundos
corpo brutoOs 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.