Verifying webhook signatures
Every webhook delivery includes an X-Flowsta-Signature header. You must verify it before trusting the payload — otherwise an attacker who knows your webhook URL could forge events.
How the signature is computed
Flowsta computes:
signature = HMAC-SHA256(secret, raw_request_body).digest('hex')secret— the hex string returned when you created the webhook. Store it securely; it is only returned onPOST /api/v1/webhooks.raw_request_body— the exact bytes Flowsta sent, before any JSON parsing or whitespace normalisation. You must verify against the raw body, not a re-serialised version.
The result is sent in the X-Flowsta-Signature header as lowercase hex.
Flowsta also sends the event type in the X-Flowsta-Event header so you can route before parsing.
Node.js (Express)
Use express.raw() on your webhook route so you can read the unmodified bytes.
js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.FLOWSTA_WEBHOOK_SECRET;
app.post(
'/hooks/flowsta',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Flowsta-Signature');
if (!signature) return res.status(401).end();
const expected = crypto
.createHmac('sha256', SECRET)
.update(req.body) // Buffer of raw bytes
.digest('hex');
// Constant-time compare to avoid timing attacks
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
console.log('Received:', event.event, event);
res.status(200).end();
}
);Python (Flask)
python
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["FLOWSTA_WEBHOOK_SECRET"].encode()
@app.post("/hooks/flowsta")
def flowsta_hook():
signature = request.headers.get("X-Flowsta-Signature", "")
expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
print("Received:", event["event"], event)
return "", 200Things to get right
- Compare bytes-to-bytes with a constant-time function (
crypto.timingSafeEqual,hmac.compare_digest). Plain string equality leaks timing information. - Store the secret in an environment variable, not in source. If the secret leaks, delete and recreate the webhook.
- Return 2xx within 10 seconds. Non-2xx responses count as a delivery failure (see Delivery & Failures).
- One secret per webhook. Each webhook has its own independent secret. Rotate by creating a new webhook and deleting the old one.