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:00The 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
- Get the raw body from the request as-is. Don't parse and re-serialise.
- Read
X-Feedjolt-Signature; strip thesha256=prefix. - Compute
HMAC-SHA256(secret, raw_body)as hex. - 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 "", 200FastAPI:
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)
endIn 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
endGo
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_idin 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.
Webhook payloads
The Feedjolt webhook event types and the headers that frame each delivery. Covers payload shape, the signature and event headers, idempotency, and stability.
Webhook retries and idempotency
How Feedjolt handles webhook delivery failures today: one attempt, delivery logs, manual replay, and how to build idempotent, out-of-order-safe handlers.
