Documentation Index
Fetch the complete documentation index at: https://docs.fanfare.io/llms.txt
Use this file to discover all available pages before exploring further.
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:
- The timestamp of the request
- The raw request body
- Your webhook signing secret
Each webhook request includes these headers:
| Header | Description |
|---|
X-Fanfare-Signature | The HMAC-SHA256 signature |
X-Fanfare-Timestamp | Unix timestamp when the request was sent |
Verification Process
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");
}
Step 5: Check Timestamp (Recommended)
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:
- Go to Settings > Webhooks in your dashboard
- Create or edit an endpoint
- Copy the signing secret (begins with
whsec_)
Rotating Secrets
To rotate your webhook secret:
- Generate a new secret in the dashboard
- Update your server to accept both old and new secrets temporarily
- Verify webhooks are working with the new secret
- 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:
- Wrong secret: Ensure you’re using the correct webhook secret
- Body modification: Middleware may have modified the raw body
- Encoding issues: Ensure consistent UTF-8 encoding
- Header case sensitivity: Some frameworks lowercase headers
Timestamp Validation Failed
If timestamp validation fails:
- Check your server’s clock is synchronized (use NTP)
- Verify the timestamp header is being read correctly
- 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);