Verificar la firma
Cada entrega va firmada. Validar la firma es lo que impide que alguien falsifique mensajes a tu endpoint — la URL es pública, así que sin validación cualquiera puede publicar en ella.
El algoritmo
firma = HMAC-SHA256(secreto, "{timestamp}.{cuerpo bruto}")
En hexadecimal, con el prefijo sha256=.
| Componente | De dónde sale |
|---|---|
secreto | El whsec_... del endpoint |
timestamp | La cabecera X-Joinotify-Timestamp, Unix en segundos |
cuerpo bruto | Los bytes del cuerpo, antes de cualquier parseo |
El timestamp entra en el HMAC a propósito: sin él, quien interceptara una entrega válida podría reenviarla indefinidamente. Compáralo con el reloj actual y rechaza lo que sea demasiado viejo — 300 segundos es la tolerancia recomendada.
Node.js + Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const TOLERANCIA_SEGUNDOS = 300;
// La ruta del webhook necesita el cuerpo BRUTO. Regístrala ANTES de cualquier
// middleware de JSON — express.json() reserializa el payload y la firma deja
// de coincidir.
app.post('/webhooks/joinotify', express.raw({ type: 'application/json' }), (req, res) => {
const firma = 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 esperada =
'sha256=' +
crypto.createHmac('sha256', process.env.JOINOTIFY_WEBHOOK_SECRET).update(`${timestamp}.${req.body}`).digest('hex');
const a = Buffer.from(firma);
const b = Buffer.from(esperada);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
res.sendStatus(200);
procesarEnSegundoPlano(JSON.parse(req.body.toString()));
});
app.use(express.json()); // demás rutas
Python + Flask
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
TOLERANCIA_SEGUNDOS = 300
SECRETO = os.environ["JOINOTIFY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/joinotify")
def webhook():
firma = 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() devuelve los bytes brutos; request.json los parsearía.
cuerpo = request.get_data()
esperada = "sha256=" + hmac.new(SECRETO, f"{timestamp}.".encode() + cuerpo, hashlib.sha256).hexdigest()
if not hmac.compare_digest(firma, esperada):
return "", 401
encolar(request.get_json())
return "", 200
PHP
<?php
$tolerancia = 300;
$secreto = getenv('JOINOTIFY_WEBHOOK_SECRET');
$firma = $_SERVER['HTTP_X_JOINOTIFY_SIGNATURE_256'] ?? '';
$timestamp = $_SERVER['HTTP_X_JOINOTIFY_TIMESTAMP'] ?? '0';
$cuerpo = file_get_contents('php://input');
if (abs(time() - (int) $timestamp) > $tolerancia) {
http_response_code(401);
exit;
}
$esperada = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $cuerpo, $secreto);
if (!hash_equals($esperada, $firma)) {
http_response_code(401);
exit;
}
http_response_code(200);
fastcgi_finish_request(); // responde antes de procesar
encolar(json_decode($cuerpo, true));
Por qué la firma no coincide
Casi siempre es una de estas cuatro.
El cuerpo se parseó antes. express.json(), bodyParser, request.json — todos
reserializan y cambian espacios y orden de claves. Usa los bytes brutos.
El timestamp quedó fuera. El HMAC cubre "{timestamp}.{cuerpo}", no el cuerpo solo.
Este es el error más común de quien viene de Meta, donde la firma cubre solo el cuerpo.
Se olvidó el prefijo sha256=. Forma parte del valor comparado.
Un proxy alteró el cuerpo. Algunos proxies inversos reescriben el JSON. Si estás detrás de uno, confirma que reenvía el cuerpo intacto.
Siempre con comparación en tiempo constante
crypto.timingSafeEqual, hmac.compare_digest, hash_equals. Nunca ==.
Una comparación normal se detiene en el primer byte distinto, y esa diferencia de tiempo es medible — basta para descubrir la firma correcta byte a byte. Las funciones anteriores tardan lo mismo independientemente de dónde esté la diferencia.
Probar
curl -X POST https://api.joinotify.com/webhook-endpoints/whe_01J8ZK.../test \
-H 'Authorization: Bearer sk_live_xxx'
responseStatus: 200 en la respuesta significa que tu validación pasó. 401 significa
que está rechazando entregas legítimas — repasa la lista anterior.