Resources

Verifying webhook signatures

Floo signs every outbound webhook with HMAC-SHA256 using the agent's webhook secret. Verifying the signature before acting on a payload prevents replay attacks and confirms the request originated from Floo.

How it works

Every POST we send includes an X-Floo-Signature header of the form:

BASH
X-Floo-Signature: sha256=4ed9b... (64 hex chars)

The hex portion is HMAC-SHA256(secret, raw_request_body). To verify:

  1. Read the raw bytes of the request body (do not re-serialize).
  2. Compute HMAC-SHA256over the raw body using the agent's webhook secret.
  3. Strip the sha256= prefix from the header and compare the two hex strings in constant time to avoid timing leaks.
  4. If they don't match, reject with 401 and stop processing.
Use the RAW body. Frameworks that auto-parse JSON (Express json(), FastAPI) will re-serialize whitespace and your HMAC will silently mismatch ours. Capture req.rawBody / request.body bytes before parsing.

Node.js (Express)

Express handler - verify and respondts
700 font-semibold">import crypto 700 font-semibold">from "node:crypto";
700 font-semibold">import express 700 font-semibold">from "express";

700 font-semibold">const app = express();

700 font-semibold">class="text-gray-500 italic">// IMPORTANT: use express.raw - JSON.stringify(req.body) will NOT match
700 font-semibold">class="text-gray-500 italic">// our signature once Express has re-serialized whitespace.
app.post(
  "/webhooks/floo",
  express.raw({ 700 font-semibold">type: "application/json" }),
  (req, res) => {
    700 font-semibold">const signatureHeader = req.header("X-Floo-Signature") ?? "";
    700 font-semibold">const secret = process.env.FLOO_WEBHOOK_SECRET ?? "";

    700 font-semibold">const expected = crypto
      .createHmac("sha256", secret)
      .update(req.body) 700 font-semibold">class="text-gray-500 italic">// Buffer 700 font-semibold">of raw bytes
      .digest("hex");
    700 font-semibold">const provided = signatureHeader.replace(/^sha256=/, "");

    700 font-semibold">const a = Buffer.700 font-semibold">from(expected, "hex");
    700 font-semibold">const b = Buffer.700 font-semibold">from(provided, "hex");
    700 font-semibold">if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      700 font-semibold">return res.status(401).send("invalid signature");
    }

    700 font-semibold">const event = JSON.parse(req.body.toString("utf8"));
    700 font-semibold">class="text-gray-500 italic">// ...handle event...
    res.sendStatus(200);
  },
);

Python (Flask)

Flask handler - verify and respondpy
700 font-semibold">import hmac
700 font-semibold">import hashlib
700 font-semibold">import os
700 font-semibold">from flask 700 font-semibold">import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/floo")
700 font-semibold">def floo_webhook():
    signature_header = request.headers.get("X-Floo-Signature", "")
    secret = os.environ["FLOO_WEBHOOK_SECRET"]

    700 font-semibold">class="text-gray-500 italic"># request.get_data() returns raw bytes - do NOT use request.json
    700 font-semibold">class="text-gray-500 italic"># before this, 700 font-semibold">or Flask may consume the stream.
    raw_body = request.get_data()

    expected = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    provided = signature_header.removeprefix("sha256=")

    700 font-semibold">if 700 font-semibold">not hmac.compare_digest(expected, provided):
        abort(401, "invalid signature")

    event = request.get_json()
    700 font-semibold">class="text-gray-500 italic"># ...handle event...
    700 font-semibold">return "", 200

Bash (openssl)

Handy for one-off debugging: save the payload to a file, then pipe it through openssl dgst and compare with cmp or string equality.

verify.shbash
#!/usr/bin/env bash
# Usage:
#   FLOO_WEBHOOK_SECRET=whsec_xxx ./verify.sh body.json "sha256=<hex>"

set -euo pipefail

BODY_FILE="${1:-body.json}"
PROVIDED_HEADER="${2:?missing signature header}"
SECRET="${FLOO_WEBHOOK_SECRET:?missing FLOO_WEBHOOK_SECRET}"

PROVIDED="${PROVIDED_HEADER#sha256=}"

EXPECTED=$(openssl dgst -sha256 -hmac "$SECRET" -hex "$BODY_FILE" \
  | awk '{print $2}')

if [ "${#EXPECTED}" -ne "${#PROVIDED}" ]; then
  echo "invalid signature (length mismatch)" >&2
  exit 1
fi
if [ "$EXPECTED" != "$PROVIDED" ]; then
  echo "invalid signature" >&2
  exit 1
fi
echo "ok"

Common pitfalls

  • Re-serialized JSON. Any framework that parses the body before you see it will re-serialize whitespace. Always capture the raw bytes.
  • Wrong secret.Each agent has its own webhook secret - make sure your handler is using the secret for the agent that's sending the event (the payload includes agent_id).
  • String compare leak. Don't use === / == for the final compare - attackers can deduce bytes from response timing. Use timingSafeEqual / hmac.compare_digest.
  • Encoding. HMAC the raw bytes, not a decoded string. UTF-8 vs latin-1 mismatches will silently break.

Debugging from the dashboard

If you're sure your code is correct but signatures still mismatch, paste the body and signature into the webhook debugger in the dashboard. It calls POST /api/internal/webhooks/test-signature and shows the exact HMAC we would have produced - usually surfaces an encoding or whitespace bug in seconds.

See also: Webhooks for the full list of events and payload schemas.