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: t=1762500000,v1=6ebcb8977b8e4c24f4808b5c36b6016fc5fcdbd1963f2566b34669feace5199ftis a Unix time in seconds: the moment this try left Fluveo. A retry gets a newtand a newv1, so a real retry is never too old.v1is 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 in1762500000.{"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.
| Input | Value |
|---|---|
| Secret | whsec_example |
| Raw body | {"id":"evt_example","object":"event"} |
t | 1762500000 |
| Signed payload | 1762500000.{"id":"evt_example","object":"event"} |
Expected v1 | 6ebcb8977b8e4c24f4808b5c36b6016fc5fcdbd1963f2566b34669feace5199f |
Check it step by step#
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.Read t and v1 from the header#
The header is a comma-separated list of
key=valuepairs. Taketand everyv1you find, and ignore any other key. Not, atthat is not a whole number of 12 digits or fewer, or nov1at all: reject the request.Check the time#
Compare
twith 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.Compute the expected signature#
Build the string
t+.+ raw body. Run HMAC-SHA256 on it, keyed with the fullwhsec_...string, and write the result as lowercase hex.Compare in constant time#
Hold your hex result against each
v1with a comparison whose run time does not depend on where the strings differ. A realv1is 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 reply400and stop.Parse and handle#
Now, and not before, parse the JSON. Skip an event
idyou already handled, store the event and reply200.
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.
// 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.
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#
| Cause | Fix |
|---|---|
| The body was parsed and serialized again before the check | Use 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 live | Each endpoint has its own whsec_. Use the one of the endpoint that received the call. |
The whsec_ prefix was removed, or the secret was decoded | Use the whole string as it was shown, as UTF-8 bytes. |
| The secret was rotated in the Dashboard | With 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 drifted | Run 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
v1per secret, the new one first:t=1762500000,v1=<new>,v1=<old>. The handlers above already accept a delivery when anyv1matches, 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'sv1only.
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.