Saltar al contenido principal

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

ComponenteDe dónde sale
secretoEl whsec_... del endpoint
timestampLa cabecera X-Joinotify-Timestamp, Unix en segundos
cuerpo brutoLos 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.