Skip to content

Webhook Security

Updated Feb 2026

Firecrawl signs every webhook request using HMAC-SHA256. Verifying signatures ensures that incoming requests are authentic and have not been tampered with in transit.

Secret Key

Your webhook secret is available in the Advanced tab of your Firecrawl account settings. Each account has a unique secret used to sign all outgoing webhook requests.

Keep Your Secret Secure

Never expose your webhook secret in client-side code, public repositories, or logs. If you believe your secret has been compromised, regenerate it immediately from your account settings.

Signature Verification

Each webhook request includes an X-Firecrawl-Signature header containing the HMAC-SHA256 signature:

X-Firecrawl-Signature: sha256=abc123def456...

How to Verify

  1. Extract the signature from the X-Firecrawl-Signature header
  2. Get the raw request body (before JSON parsing)
  3. Compute HMAC-SHA256 of the raw body using your webhook secret
  4. Compare signatures using a timing-safe comparison function

Use the Raw Body

The most common verification failure is computing the HMAC over JSON.stringify(parsedBody) instead of the raw request bytes. JSON serialization may reorder keys or change whitespace, producing a different hash.

Implementation

Node.js / Express

javascript
import crypto from 'crypto';
import express from 'express';

const app = express();

// IMPORTANT: Use raw body parser for the webhook route
app.use('/webhook/firecrawl', express.raw({ type: 'application/json' }));

app.post('/webhook/firecrawl', (req, res) => {
  const signature = req.get('X-Firecrawl-Signature');
  const webhookSecret = process.env.FIRECRAWL_WEBHOOK_SECRET;

  if (!signature || !webhookSecret) {
    return res.status(401).send('Unauthorized');
  }

  // Extract hash from "sha256=<hex>" format
  const [algorithm, hash] = signature.split('=');
  if (algorithm !== 'sha256') {
    return res.status(401).send('Invalid signature algorithm');
  }

  // Compute expected signature from raw body
  const expectedSignature = crypto
    .createHmac('sha256', webhookSecret)
    .update(req.body) // Raw buffer, NOT parsed JSON
    .digest('hex');

  // Timing-safe comparison to prevent timing attacks
  if (!crypto.timingSafeEqual(
    Buffer.from(hash, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  )) {
    return res.status(401).send('Invalid signature');
  }

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

  res.status(200).send('ok');
});

app.listen(3000, () => console.log('Webhook server listening on port 3000'));

Python / Flask

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

app = Flask(__name__)
WEBHOOK_SECRET = "your-webhook-secret"  # Load from env in production

@app.route('/webhook/firecrawl', methods=['POST'])
def handle_webhook():
    signature_header = request.headers.get('X-Firecrawl-Signature', '')

    if not signature_header:
        abort(401, 'Missing signature')

    # Extract hash from "sha256=<hex>" format
    parts = signature_header.split('=', 1)
    if len(parts) != 2 or parts[0] != 'sha256':
        abort(401, 'Invalid signature format')

    received_hash = parts[1]

    # Compute expected signature from raw body
    expected_hash = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        request.get_data(),  # Raw bytes, NOT request.json
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    if not hmac.compare_digest(received_hash, expected_hash):
        abort(401, 'Invalid signature')

    # Process the verified webhook
    event = request.get_json()
    print(f"Verified Firecrawl webhook: {event['type']}")

    return 'ok', 200

if __name__ == '__main__':
    app.run(port=3000)

Go

go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "strings"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Firecrawl-Signature")
    if signature == "" {
        http.Error(w, "Missing signature", http.StatusUnauthorized)
        return
    }

    parts := strings.SplitN(signature, "=", 2)
    if len(parts) != 2 || parts[0] != "sha256" {
        http.Error(w, "Invalid signature format", http.StatusUnauthorized)
        return
    }

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }

    secret := []byte("your-webhook-secret") // Load from env
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expectedMAC := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(parts[1]), []byte(expectedMAC)) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}

Best Practices

Always Verify Signatures

Never process a webhook payload without verifying its signature first. Unverified webhooks could be spoofed by attackers to inject malicious data into your systems.

javascript
app.post('/webhook', (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).send('Unauthorized');
  }
  processWebhook(req.body);
  res.status(200).send('OK');
});

Use Timing-Safe Comparisons

Standard string comparison (=== in JS, == in Python) can leak timing information through side-channel attacks. Always use:

LanguageFunction
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
Gohmac.Equal()
RubyRack::Utils.secure_compare()

Use HTTPS

Always use HTTPS for your webhook endpoint URL. This ensures payloads are encrypted in transit and cannot be intercepted by network-level attackers.

Rotate Secrets Periodically

Even if your secret has not been compromised, rotating it periodically is good security hygiene. When you regenerate your secret in the Firecrawl dashboard, update your server environment immediately -- there is no grace period where both old and new secrets are valid.