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:
X-Floo-Signature: sha256=4ed9b... (64 hex chars)The hex portion is HMAC-SHA256(secret, raw_request_body). To verify:
- Read the raw bytes of the request body (do not re-serialize).
- Compute
HMAC-SHA256over the raw body using the agent's webhook secret. - Strip the
sha256=prefix from the header and compare the two hex strings in constant time to avoid timing leaks. - If they don't match, reject with 401 and stop processing.
json(), FastAPI) will re-serialize whitespace and your HMAC will silently mismatch ours. Capture req.rawBody / request.body bytes before parsing.Node.js (Express)
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)
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 "", 200Bash (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.
#!/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. UsetimingSafeEqual/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.