Docs
API 2026-05-27.dahlia Dashboard

Webhooks

Check webhook signatures

Use with AI

Prove that a webhook came from Fluveo and was not changed, with a few lines of code and no SDK.

Your webhook URL is public, so a request that reaches it may not come from Fluveo. The Fluveo-Signature header settles that. A good signature tells you that the body left Fluveo with this exact content, signed with a secret only you and Fluveo hold, and that it was signed a moment ago: a copy of an old delivery sent again by someone else fails the check.

You need the endpoint's signing secret, the whsec_... value Fluveo showed when you created the endpoint. Each endpoint has its own secret.

The header#

Every delivery carries this header:

Fluveo-Signature
Fluveo-Signature: t=1762500000,v1=6ebcb8977b8e4c24f4808b5c36b6016fc5fcdbd1963f2566b34669feace5199f
  • t is a Unix time in seconds: the moment this try left Fluveo. A retry gets a new t and a new v1, so a real retry is never too old.
  • v1 is an HMAC-SHA256, written as 64 lowercase hex characters, over the signed payload.
  • The signed payload is one string: t, a dot, then the raw body, as in 1762500000.{"id":"evt_...",...}.

The delivery also has Content-Type: application/json; charset=utf-8 and a User-Agent that starts with Fluveo/.

A worked example#

Use these values to test your own verify function before you point it at real deliveries. With the time check turned off (or now set to 1762500000), it must accept them.

InputValue
Secretwhsec_example
Raw body{"id":"evt_example","object":"event"}
t1762500000
Signed payload1762500000.{"id":"evt_example","object":"event"}
Expected v16ebcb8977b8e4c24f4808b5c36b6016fc5fcdbd1963f2566b34669feace5199f

Check it step by step#

  1. Read the raw body#

    The signature covers the bytes Fluveo sent, so keep them as they came. Grab the body before a JSON parser or a middleware rewrites it: express.raw({ type: "application/json" }) on the webhook route in Express, request.get_data() in Flask, file_get_contents('php://input') in PHP.

  2. Read t and v1 from the header#

    The header is a comma-separated list of key=value pairs. Take t and every v1 you find, and ignore any other key. No t, a t that is not a whole number of 12 digits or fewer, or no v1 at all: reject the request.

  3. Check the time#

    Compare t with your own clock. A gap of more than 300 seconds, either way, means reject. Run NTP on your server so its clock does not drift.

  4. Compute the expected signature#

    Build the string t + . + raw body. Run HMAC-SHA256 on it, keyed with the full whsec_... string, and write the result as lowercase hex.

  5. Compare in constant time#

    Hold your hex result against each v1 with a comparison whose run time does not depend on where the strings differ. A real v1 is always 64 lowercase hex characters: never change its case before you compare, and treat any other form as a mismatch (the Node handler below drops it before comparing). One match means the webhook is real. No match means reply 400 and stop.

  6. Parse and handle#

    Now, and not before, parse the JSON. Skip an event id you already handled, store the event and reply 200.

Complete handlers#

Each handler below runs on the standard library alone, listens on port 4242, and reads the secret from the FLUVEO_WEBHOOK_SECRET environment variable. verify is the part to copy into your own framework.

Verify Fluveo-Signature
// node server.mjs  (Node 18 or newer, no packages)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.FLUVEO_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

export function verify(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  if (typeof header !== "string") return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const at = part.indexOf("=");
    if (at === -1) continue;
    const key = part.slice(0, at).trim();
    const value = part.slice(at + 1).trim();
    if (key === "t") timestamp = value;
    if (key === "v1") signatures.push(value);
  }
  if (!/^\d{1,12}$/.test(timestamp ?? "") || signatures.length === 0) return false;
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  return signatures.some((signature) => {
    if (!/^[0-9a-f]{64}$/.test(signature)) return false;
    return timingSafeEqual(Buffer.from(signature, "hex"), expected);
  });
}

const seen = new Set(); // use a table with a unique key on the event id in production

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks/fluveo") {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks);
    if (!verify(rawBody, req.headers["fluveo-signature"], SECRET)) {
      res.writeHead(400).end();
      return;
    }
    const event = JSON.parse(rawBody.toString("utf8"));
    if (!seen.has(event.id)) {
      seen.add(event.id);
      console.log("received", event.type, event.id); // queue the real work here
    }
    res.writeHead(200).end();
  });
}).listen(4242);
# python3 server.py  (Python 3.9 or newer, no packages)
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["FLUVEO_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECONDS = 300


def verify(raw_body, header, secret, now=None):
    if not header:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, sep, value = part.strip().partition("=")
        if not sep:
            continue
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if not timestamp or len(timestamp) > 12 or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("ascii") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)


seen = set()  # use a table with a unique key on the event id in production


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/webhooks/fluveo":
            self.send_response(404)
            self.end_headers()
            return
        raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not verify(raw_body, self.headers.get("Fluveo-Signature"), SECRET):
            self.send_response(400)
            self.end_headers()
            return
        event = json.loads(raw_body)
        if event["id"] not in seen:
            seen.add(event["id"])
            print("received", event["type"], event["id"])  # queue the real work here
        self.send_response(200)
        self.end_headers()


HTTPServer(("", 4242), Handler).serve_forever()
<?php
// php -S 0.0.0.0:4242 webhook.php  (PHP 8.0 or newer, no packages)
const TOLERANCE_SECONDS = 300;

function verify(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
    if ($header === null || $header === '') {
        return false;
    }
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $pair = explode('=', trim($part), 2);
        if (count($pair) !== 2) {
            continue;
        }
        if ($pair[0] === 't') {
            $timestamp = $pair[1];
        } elseif ($pair[0] === 'v1') {
            $signatures[] = $pair[1];
        }
    }
    if ($timestamp === null || strlen($timestamp) > 12 || !ctype_digit($timestamp) || $signatures === []) {
        return false;
    }
    $now = $now ?? time();
    if (abs($now - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST' || parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/webhooks/fluveo') {
    http_response_code(404);
    exit;
}

$rawBody = file_get_contents('php://input');
$secret = getenv('FLUVEO_WEBHOOK_SECRET'); // whsec_...
if (!verify($rawBody, $_SERVER['HTTP_FLUVEO_SIGNATURE'] ?? null, (string) $secret)) {
    http_response_code(400);
    exit;
}

$event = json_decode($rawBody, true);
// Skip $event['id'] if you already stored it, store the event, and queue the real work.
error_log('received ' . $event['type'] . ' ' . $event['id']);
http_response_code(200);

Check a saved delivery by hand#

To debug, save one delivery's raw body to body.json and its header value to SIG, then compute the signature with openssl. The result must equal one of the v1 values.

Recompute v1 in a shell
SIG='t=1762500000,v1=6ebcb8977b8e4c24f4808b5c36b6016fc5fcdbd1963f2566b34669feace5199f'
t=$(printf '%s' "$SIG" | tr ',' '\n' | sed -n 's/^t=//p')
expected=$( { printf '%s.' "$t"; cat body.json; } \
  | openssl dgst -sha256 -hmac "$FLUVEO_WEBHOOK_SECRET" | sed 's/^.*= //')
printf '%s' "$SIG" | tr ',' '\n' | sed -n 's/^v1=//p' | grep -qx "$expected" \
  && echo "signature ok" || echo "signature mismatch"

The time check is left out here on purpose, so you can test an old delivery.

When the check fails#

CauseFix
The body was parsed and serialized again before the checkUse the raw bytes. Turn off body parsing on the webhook route.
The secret is from another endpoint, or from test mode while the event is liveEach endpoint has its own whsec_. Use the one of the endpoint that received the call.
The whsec_ prefix was removed, or the secret was decodedUse the whole string as it was shown, as UTF-8 bytes.
The secret was rotated in the DashboardWith Now, the old secret stops at once: deploy the new one; deliveries sent before your deploy fail and are retried on the normal schedule. With an expiration, both secrets sign until the old one ends, so deploy the new secret before then.
The server's time has driftedRun NTP. The time check reads your own clock.
A proxy or framework changed the body (re-encoding, trimming)Read the body before that layer, or send the webhook route around it.

Rotating the secret#

Rotate an endpoint's secret with Rotate secret on the endpoint in the Dashboard, and choose when the current secret stops working: Now, or in 1 hour, 24 hours, 3 days or 7 days. The new secret is shown once.

  • Now: from that moment Fluveo signs with the new secret only, and the header carries a single v1. Deliveries sent before you deploy the new secret fail and are retried on the normal schedule.
  • An expiration: until it ends, every delivery and test event carries one v1 per secret, the new one first: t=1762500000,v1=<new>,v1=<old>. The handlers above already accept a delivery when any v1 matches, so deploy the new secret at any time before the old one ends. The endpoint's page shows when the previous secret stops; after that the header carries the new secret's v1 only.

Rotating again before an expiration ends keeps the earlier secret signing until its own end, so a header can hold up to four v1: the current secret and the three newest previous ones still in their time.

Next#