Verifying Webhook Signatures

When you configure a signing secret for your webhook, Catalogian includes an HMAC-SHA256 signature in the X-Catalogian-Signature header. Always verify this signature before processing the payload.

How signing works

Catalogian computes the signature by hashing the raw JSON request body with your secret:

signature = "sha256=" + HMAC-SHA256(secret, rawBody).hexDigest()

The signature is sent in the X-Catalogian-Signature header. Your server should compute the same hash and compare using a timing-safe comparison function.

Node.js

const crypto = require("crypto");

function verifySignature(secret, rawBody, signature) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// In your Express/Fastify handler:
app.post("/webhooks/catalogian", (req, res) => {
  const signature = req.headers["x-catalogian-signature"];
  const rawBody = req.rawBody; // Ensure your framework preserves raw body

  if (!signature || !verifySignature(WEBHOOK_SECRET, rawBody, signature)) {
    return res.status(401).send("Invalid signature");
  }

  // Signature valid — process the payload
  const payload = JSON.parse(rawBody);
  res.status(200).send("OK");
});

Use the raw body, not parsed JSON.The signature is computed over the exact bytes sent by Catalogian. If your framework parses and re-serializes the JSON, the whitespace may differ and the signature won't match.

Python

import hmac
import hashlib

def verify_signature(secret: str, raw_body: bytes, signature: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# In a Flask handler:
@app.route("/webhooks/catalogian", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Catalogian-Signature", "")
    if not verify_signature(WEBHOOK_SECRET, request.data, signature):
        return "Invalid signature", 401

    payload = request.json
    # Process the payload...
    return "OK", 200

Ruby

require "openssl"

def verify_signature(secret, raw_body, signature)
  expected = "sha256=" + OpenSSL::HMAC.hexdigest(
    "SHA256", secret, raw_body
  )
  Rack::Utils.secure_compare(expected, signature)
end

# In a Sinatra/Rails controller:
post "/webhooks/catalogian" do
  raw_body = request.body.read
  signature = request.env["HTTP_X_CATALOGIAN_SIGNATURE"]

  halt 401, "Invalid signature" unless verify_signature(
    ENV["WEBHOOK_SECRET"], raw_body, signature
  )

  payload = JSON.parse(raw_body)
  # Process the payload...
  status 200
end

Setting up a signing secret

When creating or updating a webhook, provide a secret value:

curl -X POST https://api.catalogian.com/v1/sources/:id/webhooks \
  -H "Authorization: Bearer $CATALOGIAN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/catalogian",
    "secret": "whsec_your_signing_secret_here"
  }'

You can also set the secret in the dashboard under Source → Webhooks → Edit.

Testing signatures locally

Generate a test signature to verify your implementation:

# Generate a test signature
echo -n '{"event":"delta","sourceId":"test"}' | \
  openssl dgst -sha256 -hmac "your_secret" | \
  awk '{print "sha256="$2}'

Understand retry behavior and failure handling. Retry Logic →