Feedjoltdocs
DesarrolladoresWebhooks

Firma de webhooks

Verifica webhooks de Feedjolt con HMAC-SHA256 sobre el cuerpo en bruto. Formato de cabecera, algoritmo de verificación, código para Node, Python, Ruby y Go, y errores.

Cada webhook incluye una cabecera de firma. Verifícala antes de confiar en el cuerpo. Sin verificación, cualquiera que conozca tu URL de endpoint puede falsificar eventos.

Formato de la cabecera

X-Feedjolt-Signature: sha256=<hex-hmac-sha256>
X-Feedjolt-Event: status.changed
X-Feedjolt-Timestamp: 2026-04-30T12:34:56.789012+00:00

La firma es HMAC-SHA256(secret, canonical_body) como hex.

El cuerpo canónico es el payload JSON serializado con claves ordenadas y sin espacios:

json.dumps(payload, sort_keys=True, separators=(",", ":"))

Esa es la secuencia exacta de bytes que firmamos en nuestro lado; debes reproducirla byte a byte para verificar. La cabecera X-Feedjolt-Timestamp es informativa - no forma parte de lo que firmamos.

Algoritmo de verificación

  1. Obtén el cuerpo crudo de la petición tal cual. No lo parsees y re-serialices.
  2. Lee X-Feedjolt-Signature; quita el prefijo sha256=.
  3. Calcula HMAC-SHA256(secret, raw_body) como hex.
  4. Compara con igualdad de tiempo constante. Discrepancia -> rechaza con 401.

Crucial: firmamos los bytes crudos que vas a recibir. Si tu framework parsea JSON antes de que puedas leer el cuerpo, tendrás que capturar los bytes crudos por separado (ejemplos abajo).

Node.js / TypeScript

import { createHmac, timingSafeEqual } from "crypto";

const SECRET = process.env.FEEDJOLT_WEBHOOK_SECRET!;

export function verifyFeedjolt(headerValue: string | undefined, rawBody: string): boolean {
  if (!headerValue) return false;
  const [scheme, sig] = headerValue.split("=");
  if (scheme !== "sha256" || !sig) return false;

  const expected = createHmac("sha256", SECRET).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  return a.length === b.length && timingSafeEqual(a, b);
}

Express necesita captura personalizada del cuerpo crudo (el express.json() por defecto descarta los bytes):

import express from "express";

const app = express();

app.use("/feedjolt-webhook", express.json({
  verify: (req, _res, buf) => {
    (req as any).rawBody = buf.toString("utf8");
  }
}));

app.post("/feedjolt-webhook", (req, res) => {
  const ok = verifyFeedjolt(
    req.header("X-Feedjolt-Signature") ?? undefined,
    (req as any).rawBody
  );
  if (!ok) return res.status(401).end();

  const event = req.body;
  // procesar evento
  res.status(200).end();
});

Next.js Route Handlers (App Router):

// app/api/feedjolt-webhook/route.ts
import { NextResponse } from "next/server";
import { verifyFeedjolt } from "@/lib/feedjolt";

export async function POST(req: Request) {
  const rawBody = await req.text();
  const ok = verifyFeedjolt(req.headers.get("x-feedjolt-signature") ?? undefined, rawBody);
  if (!ok) return new NextResponse("invalid signature", { status: 401 });

  const event = JSON.parse(rawBody);
  // procesar evento
  return new NextResponse("ok");
}

Python

import hashlib
import hmac
import os

SECRET = os.environ["FEEDJOLT_WEBHOOK_SECRET"]

def verify_feedjolt(header_value: str | None, raw_body: bytes) -> bool:
    if not header_value:
        return False
    scheme, _, sig = header_value.partition("=")
    if scheme != "sha256" or not sig:
        return False
    expected = hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Flask:

@app.post("/feedjolt-webhook")
def webhook():
    raw = request.get_data()  # bytes, sin parsear
    if not verify_feedjolt(request.headers.get("X-Feedjolt-Signature"), raw):
        abort(401)
    event = request.get_json()
    return "", 200

FastAPI:

from fastapi import FastAPI, Request, HTTPException

@app.post("/feedjolt-webhook")
async def webhook(request: Request):
    raw = await request.body()
    if not verify_feedjolt(request.headers.get("x-feedjolt-signature"), raw):
        raise HTTPException(401)
    event = await request.json()
    return {"ok": True}

Ruby (Rails)

require "openssl"

SECRET = ENV.fetch("FEEDJOLT_WEBHOOK_SECRET")

def verify_feedjolt(header_value, raw_body)
  return false unless header_value
  scheme, sig = header_value.split("=", 2)
  return false unless scheme == "sha256" && sig
  expected = OpenSSL::HMAC.hexdigest("sha256", SECRET, raw_body)
  Rack::Utils.secure_compare(expected, sig)
end

En un controlador de Rails (request.raw_post obtiene los bytes sin parsear):

class FeedjoltWebhooksController < ActionController::API
  skip_before_action :verify_authenticity_token, raise: false

  def create
    raw = request.raw_post
    head :unauthorized and return unless verify_feedjolt(request.headers["X-Feedjolt-Signature"], raw)
    event = JSON.parse(raw)
    head :ok
  end
end

Go

package feedjolt

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strings"
)

func Verify(secret, headerValue string, rawBody []byte) bool {
	if !strings.HasPrefix(headerValue, "sha256=") {
		return false
	}
	sig := strings.TrimPrefix(headerValue, "sha256=")
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(sig))
}

Errores comunes

  • Parsear el cuerpo antes de verificar. Los frameworks parsean JSON por ti y pierden espacios. Firmamos los bytes exactos - calcula sobre el cuerpo crudo.
  • Comparar strings con ==. Usa una comparación de tiempo constante. Si no, un atacante remoto puede extraer bytes vía timing.
  • Confiar en el secreto equivocado. Si tienes varios endpoints, cada uno tiene su propio secreto. Usa el correcto para el endpoint receptor.

Protección contra replay

Actualmente no incluimos una marca de tiempo en los bytes firmados, así que si un atacante captura una entrega podría en principio replayarla indefinidamente. Mitigaciones a aplicar encima:

  • Trata la propia URL del endpoint como un secreto. No la registres; rótala si se filtra.
  • Deduplica por event_id en el payload - un replay lleva el mismo ID de evento, así que lo saltarás tras el primer procesado.

Un esquema de timestamp firmado (estilo Stripe t=...,v1=...) está en la hoja de ruta. Vótalo si necesitas mejor protección contra replay hoy.

Replay manual

El panel en Ajustes -> Webhooks -> [endpoint] -> Entregas tiene un botón Replay en cada entrega. Reencola el mismo payload - útil cuando arreglaste un bug y quieres reprocesar los eventos de ayer.

On this page