Skip to main content

Webhook Signatures

Every webhook request from Fanfare includes a cryptographic signature that allows you to verify the request’s authenticity. Always verify signatures before processing webhook events.

Signature Overview

Fanfare uses HMAC-SHA256 to sign webhook payloads. The signature is computed from:
  1. The timestamp of the request
  2. The raw request body
  3. Your webhook signing secret

Signature Headers

Each webhook request includes these headers:
HeaderDescription
X-Fanfare-SignatureThe HMAC-SHA256 signature
X-Fanfare-TimestampUnix timestamp when the request was sent

Verification Process

Step 1: Extract Headers

const signature = req.headers["x-fanfare-signature"] as string;
const timestamp = req.headers["x-fanfare-timestamp"] as string;

Step 2: Prepare the Signed Payload

Concatenate the timestamp and the raw request body with a period:
const signedPayload = `${timestamp}.${rawBody}`;

Step 3: Compute Expected Signature

import crypto from "crypto";

const expectedSignature = crypto.createHmac("sha256", webhookSecret).update(signedPayload).digest("hex");

Step 4: Compare Signatures

// The signature header format is "sha256=<signature>"
const expectedFull = `sha256=${expectedSignature}`;

if (signature !== expectedFull) {
  throw new Error("Invalid signature");
}
Prevent replay attacks by rejecting old webhooks:
const MAX_AGE_SECONDS = 300; // 5 minutes
const now = Math.floor(Date.now() / 1000);
const timestampAge = now - parseInt(timestamp, 10);

if (timestampAge > MAX_AGE_SECONDS) {
  throw new Error("Webhook timestamp too old");
}

Complete Verification Example

Node.js / Express

import express from "express";
import crypto from "crypto";

const app = express();

// Use raw body for signature verification
app.use("/webhooks/fanfare", express.raw({ type: "application/json" }));

function verifyWebhookSignature(payload: Buffer, signature: string, timestamp: string, secret: string): boolean {
  // Check timestamp age (prevent replay attacks)
  const MAX_AGE_SECONDS = 300;
  const now = Math.floor(Date.now() / 1000);
  const timestampAge = now - parseInt(timestamp, 10);

  if (timestampAge > MAX_AGE_SECONDS) {
    console.error("Webhook timestamp too old:", timestampAge, "seconds");
    return false;
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");

  const expectedFull = `sha256=${expectedSignature}`;

  // Use timing-safe comparison
  try {
    return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedFull));
  } catch {
    return false;
  }
}

app.post("/webhooks/fanfare", (req, res) => {
  const signature = req.headers["x-fanfare-signature"] as string;
  const timestamp = req.headers["x-fanfare-timestamp"] as string;

  if (!signature || !timestamp) {
    return res.status(400).send("Missing signature headers");
  }

  const isValid = verifyWebhookSignature(req.body, signature, timestamp, process.env.WEBHOOK_SECRET!);

  if (!isValid) {
    console.error("Invalid webhook signature");
    return res.status(401).send("Invalid signature");
  }

  // Parse and process the event
  const event = JSON.parse(req.body.toString());
  console.log("Verified webhook event:", event.type);

  // Handle the event
  handleEvent(event);

  res.status(200).send("OK");
});

app.listen(3000);

Next.js API Route

import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export async function POST(req: NextRequest) {
  const signature = req.headers.get("x-fanfare-signature");
  const timestamp = req.headers.get("x-fanfare-timestamp");

  if (!signature || !timestamp) {
    return NextResponse.json({ error: "Missing signature headers" }, { status: 400 });
  }

  const rawBody = await req.text();

  // Verify signature
  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSignature = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(signedPayload)
    .digest("hex");

  if (signature !== `sha256=${expectedSignature}`) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // Parse and process
  const event = JSON.parse(rawBody);

  // Process event...

  return NextResponse.json({ received: true });
}

Python / Flask

import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

WEBHOOK_SECRET = "your_webhook_secret"
MAX_AGE_SECONDS = 300

def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
    # Check timestamp age
    now = int(time.time())
    timestamp_int = int(timestamp)
    if now - timestamp_int > MAX_AGE_SECONDS:
        return False

    # Compute expected signature
    signed_payload = f"{timestamp}.{payload.decode()}"
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    expected_full = f"sha256={expected}"

    # Timing-safe comparison
    return hmac.compare_digest(signature, expected_full)

@app.route("/webhooks/fanfare", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Fanfare-Signature")
    timestamp = request.headers.get("X-Fanfare-Timestamp")

    if not signature or not timestamp:
        abort(400)

    if not verify_signature(request.data, signature, timestamp):
        abort(401)

    event = request.json
    print(f"Received event: {event['type']}")

    # Process event...

    return "OK", 200

Webhook Secrets

Obtaining Your Secret

Your webhook signing secret is provided when you create a webhook endpoint:
  1. Go to Settings > Webhooks in your dashboard
  2. Create or edit an endpoint
  3. Copy the signing secret (begins with whsec_)

Rotating Secrets

To rotate your webhook secret:
  1. Generate a new secret in the dashboard
  2. Update your server to accept both old and new secrets temporarily
  3. Verify webhooks are working with the new secret
  4. Remove the old secret from your server
const SECRETS = [process.env.WEBHOOK_SECRET_NEW, process.env.WEBHOOK_SECRET_OLD].filter(Boolean);

function verifyWithMultipleSecrets(payload, signature, timestamp) {
  for (const secret of SECRETS) {
    if (verifySignature(payload, signature, timestamp, secret)) {
      return true;
    }
  }
  return false;
}

Security Best Practices

1. Use Timing-Safe Comparison

Always use timing-safe comparison to prevent timing attacks:
// Good: timing-safe
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Bad: vulnerable to timing attacks
signature === expectedSignature;

2. Check Timestamp Age

Prevent replay attacks by rejecting old webhooks:
const MAX_AGE_SECONDS = 300; // 5 minutes
if (now - timestamp > MAX_AGE_SECONDS) {
  reject();
}

3. Store Secrets Securely

Never commit webhook secrets to version control. Use environment variables:
# .env (not committed)
WEBHOOK_SECRET=whsec_xxxxxxxxxxxx

4. Use Raw Body

Parse the body as raw bytes before JSON parsing:
// Express
app.use("/webhooks", express.raw({ type: "application/json" }));

// Then parse after verification
const event = JSON.parse(rawBody.toString());

5. Log Failed Verifications

Monitor for signature failures which may indicate attacks:
if (!isValid) {
  console.error("Webhook signature verification failed", {
    signature,
    timestamp,
    ip: req.ip,
  });
}

Troubleshooting

Signature Mismatch

Common causes:
  1. Wrong secret: Ensure you’re using the correct webhook secret
  2. Body modification: Middleware may have modified the raw body
  3. Encoding issues: Ensure consistent UTF-8 encoding
  4. Header case sensitivity: Some frameworks lowercase headers

Timestamp Validation Failed

If timestamp validation fails:
  1. Check your server’s clock is synchronized (use NTP)
  2. Verify the timestamp header is being read correctly
  3. Consider increasing the max age temporarily for debugging

Testing Signature Verification

Generate a test signature to verify your implementation:
const secret = "whsec_test";
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = '{"type":"test","data":{}}';
const signedPayload = `${timestamp}.${body}`;
const signature = `sha256=${crypto.createHmac("sha256", secret).update(signedPayload).digest("hex")}`;

console.log("Timestamp:", timestamp);
console.log("Signature:", signature);