---
title: "Check webhook signatures"
description: "Prove that a webhook came from Fluveo and was not changed, with a few lines of code and no SDK."
url: https://docs.fluveo.com/webhooks/signatures/
---

> Index of every page: https://docs.fluveo.com/llms.txt. Integrating Fluveo? Read https://docs.fluveo.com/agents/integrate.md first.

> Too long? This page with code in one language only: https://docs.fluveo.com/webhooks/signatures.curl.md (cURL), https://docs.fluveo.com/webhooks/signatures.node.md (Node.js), https://docs.fluveo.com/webhooks/signatures.python.md (Python), https://docs.fluveo.com/webhooks/signatures.php.md (PHP).

# Check webhook signatures

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](https://docs.fluveo.com/webhooks.md#create). Each endpoint has its own secret.

> **For AI agents:**
>
> - Compute the HMAC over the raw request bytes. Never re-serialize parsed JSON: a single changed space breaks the match.
> - The HMAC key is the whole secret string, `whsec_` prefix included, as UTF-8 bytes. Do not strip or base64-decode it.
> - Every delivery carries one signature header, `Fluveo-Signature`.
> - Compare in constant time (`crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`). Never `==`.
> - Reject a timestamp more than 300 seconds from your clock. Retries are signed again, so this never rejects a real retry.
> - On failure answer 400 and do nothing else. Do not log the secret.

## The header

Every delivery carries this header:

*Fluveo-Signature*

```text
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.

| 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

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

### Step 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.

### Step 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.

### Step 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.

### Step 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.

### Step 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.js:

```javascript
// 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);
```

Python:

```python
# 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
<?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*

```bash
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](https://dash.fluveo.com), 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

- [Test webhooks](https://docs.fluveo.com/webhooks/testing.md): Point a test endpoint at your machine and trigger events.
- [Receive webhooks](https://docs.fluveo.com/webhooks.md): Retries, duplicates, ordering and fulfilment.
- [Event types](https://docs.fluveo.com/webhooks/events.md): Every event Fluveo sends.
