Feedjoltdocs
DevelopersWebhooks

Webhook signing

Verify Feedjolt webhooks with HMAC-SHA256 over the raw body. Header format, the verification algorithm, code for Node, Python, Ruby, and Go, plus pitfalls.

Every webhook includes a signature header. Verify it before trusting the body. Without verification, anyone who learns your endpoint URL can forge events.

Header format

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

The signature is HMAC-SHA256(secret, canonical_body) as hex.

The canonical body is the JSON payload serialised with sorted keys and no whitespace:

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

That's the exact byte sequence we sign on our side; you must reproduce it byte-for-byte to verify. The header X-Feedjolt-Timestamp is informational - it's not part of what we sign.

Verification algorithm

  1. Get the raw body from the request as-is. Don't parse and re-serialise.
  2. Read X-Feedjolt-Signature; strip the sha256= prefix.
  3. Compute HMAC-SHA256(secret, raw_body) as hex.
  4. Compare with constant-time equality. Mismatch -> reject with 401.

Crucial: we sign the raw bytes you'll receive. If your framework parses JSON before you can read the body, you'll need to capture the raw bytes separately (examples below).

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 needs a custom raw-body capture (the default express.json() discards the 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;
  // process event
  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);
  // process event
  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, not parsed
    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

In a Rails controller (request.raw_post gets the unparsed bytes):

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))
}

Common mistakes

  • Parsing the body before verifying. Frameworks parse JSON for you and lose whitespace. We sign the exact bytes - compute on the raw body.
  • Comparing strings with ==. Use a constant-time comparison. Otherwise a remote attacker can extract bytes via timing.
  • Trusting the wrong secret. If you have multiple endpoints, each has its own secret. Use the right one for the receiving endpoint.

Replay protection

We don't currently include a timestamp in the signed bytes, so if an attacker captures a delivery they could in principle replay it indefinitely. Mitigations to layer on top:

  • Treat the endpoint URL itself as a secret. Don't log it; rotate it if it leaks.
  • Dedupe by event_id in the payload - a replay carries the same event ID, so you'll skip it after the first processing.

A signed-timestamp scheme (Stripe-style t=...,v1=...) is on the roadmap. Vote it up if you need stronger replay protection today.

Manual replay

The dashboard at Settings -> Webhooks -> [endpoint] -> Deliveries has a Replay button on every delivery. It re-enqueues the same payload - useful when you fixed a bug and want to reprocess yesterday's events.

On this page