Skip to content

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 on POST /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 "", 200

Things 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.

Documentation licensed under CC BY-SA 4.0.