# Verify webhook signatures

:::summary Quick answer
To verify a Flownally webhook signature, recompute the `webhook-signature` header from the `webhook-id` header, the `webhook-timestamp` header and the raw request body, and reject the request when no signature matches. Flownally signs every delivery with [Standard Webhooks](https://www.standardwebhooks.com/), so the official `standardwebhooks` libraries verify it without changes. Also reject stale timestamps, and deduplicate on `webhook-id`.

**Verify the raw bytes before you parse JSON.**
:::

This page is the code companion to [Webhooks](/api/webhooks), which covers endpoint setup, event types, retries and key rotation.

## How does Flownally sign webhook deliveries?

Each delivery has three headers:

- `webhook-id`: the event ID. It equals the payload `id` and stays the same on every retry.
- `webhook-timestamp`: the Unix time of this attempt, in seconds. It changes on every retry.
- `webhook-signature`: one or more signatures in the form `v1,<base64>`, separated by spaces.

To compute a `v1` signature:

1. Join the headers and the body with periods: `{webhook-id}.{webhook-timestamp}.{raw body}`.
2. Remove the `whsec_` prefix from the signing key and base64-decode the rest. The decoded bytes are the HMAC key.
3. Compute an HMAC-SHA256 of the joined string and base64-encode it.
4. Compare the result with each signature in the header in constant time. Accept the delivery if any signature matches.

The signing key is the `plaintextKey` value, in the form `whsec_…`.

[`GET /webhooks/signing-keys`](/api/reference/webhooks#list-webhook-signing-keys) returns it once, on the first call in a workspace, and [`POST /webhooks/signing-keys:rotate`](/api/reference/webhooks#rotate-webhook-signing-key) returns a new one once. **Later calls return metadata only, so store the key in your secret manager when you receive it.**

## Verify signatures with the standardwebhooks library

The official libraries are `standardwebhooks` on npm and on PyPI, and `standard-webhooks/standard-webhooks` on Packagist. They check the timestamp and accept any matching signature, so you don't have to write those checks yourself.

```bash
npm install standardwebhooks
pip install standardwebhooks
composer require standard-webhooks/standard-webhooks
```

Pass the signing key exactly as Flownally returns it, including the `whsec_` prefix.

`verify` returns the parsed JSON payload. It throws (in Python, raises) `WebhookVerificationError`, or `WebhookVerificationException` in PHP, when:

- a header is missing,
- no signature matches, or
- `webhook-timestamp` is more than 5 minutes from the current time.

:::code Receive a webhook
```js
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.FLOWNALLY_WEBHOOK_SIGNING_KEY);

// Register this route before app.use(express.json()) so the body stays raw.
app.post("/flownally/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.sendStatus(401);
  }

  await enqueue(event); // Your queue. Process the event after you respond.
  res.sendStatus(204);
});
```

```python
import os

from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["FLOWNALLY_WEBHOOK_SIGNING_KEY"])


@app.post("/flownally/webhooks")
def flownally_webhooks():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        return "", 401

    enqueue(event)  # Your queue. Process the event after you respond.
    return "", 204
```

```php
<?php

use StandardWebhooks\Exception\WebhookVerificationException;
use StandardWebhooks\Webhook;

$webhook = new Webhook(getenv('FLOWNALLY_WEBHOOK_SIGNING_KEY'));

try {
    // The library expects lowercase header names.
    $event = $webhook->verify(file_get_contents('php://input'), array_change_key_case(getallheaders()));
} catch (WebhookVerificationException) {
    http_response_code(401);
    exit;
}

enqueue($event); // Your queue. Process the event after you respond.
http_response_code(204);
```
:::

## Read the raw request body before you parse JSON

The signature covers the exact bytes Flownally sent. If a framework parses the JSON and you serialize it again, whitespace and key order can change, and verification fails.

**Read the raw body → Verify it → Parse it**

- **Express.** Add `express.raw({ type: "application/json" })` to the webhook route and register it before any global `express.json()` middleware. `req.body` is then a `Buffer`.
- **Next.js route handlers.** Read `await request.text()` and don't call `request.json()` first. The body can be read only once.
- **Next.js Pages Router API routes.** Turn off the built-in parser with `export const config = { api: { bodyParser: false } }` and read the request stream.
- **Flask, Django and FastAPI.** Use `request.get_data()` in Flask, `request.body` in Django, and `await request.body()` in FastAPI.
- **PHP, Laravel and Symfony.** Use `file_get_contents('php://input')` in plain PHP, and `$request->getContent()` in Laravel and Symfony.

Next.js route handler:

```js
// app/flownally/webhooks/route.js
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.FLOWNALLY_WEBHOOK_SIGNING_KEY);

export async function POST(request) {
  const rawBody = await request.text();

  let event;
  try {
    event = webhook.verify(rawBody, Object.fromEntries(request.headers));
  } catch {
    return new Response(null, { status: 401 });
  }

  await enqueue(event);
  return new Response(null, { status: 204 });
}
```

## Verify the webhook-signature header by hand

Use this when you can't add a dependency. Each version uses only the standard library, such as `node:crypto` in Node.js. The PHP version compares with `hash_equals`, which runs in constant time, and needs PHP 8.0 or later.

:::code Verify a signature
```js
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyFlownallyWebhook(rawBody, headers, signingKey) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) {
    throw new Error("Missing webhook headers");
  }

  const sentAt = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) {
    throw new Error("Stale or invalid webhook-timestamp");
  }

  const key = Buffer.from(signingKey.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  const valid = signatureHeader.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
  if (!valid) {
    throw new Error("Invalid webhook-signature");
  }

  return JSON.parse(rawBody.toString("utf8"));
}
```

```python
import base64
import hashlib
import hmac
import json
import time

TOLERANCE_SECONDS = 5 * 60


def verify_flownally_webhook(raw_body: bytes, headers, signing_key: str) -> dict:
    webhook_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not webhook_id or not timestamp or not signature_header:
        raise ValueError("Missing webhook headers")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("Stale or invalid webhook-timestamp")

    key = base64.b64decode(signing_key.removeprefix("whsec_"))
    signed_content = f"{webhook_id}.{timestamp}.".encode() + raw_body
    digest = hmac.new(key, signed_content, hashlib.sha256).digest()
    expected = b"v1," + base64.b64encode(digest)

    for signature in signature_header.split(" "):
        if hmac.compare_digest(signature.encode(), expected):
            return json.loads(raw_body)
    raise ValueError("Invalid webhook-signature")
```

```php
<?php

const TOLERANCE_SECONDS = 5 * 60;

function verifyFlownallyWebhook(string $rawBody, array $headers, string $signingKey): array
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signatureHeader = $headers['webhook-signature'] ?? '';
    if ($id === '' || $timestamp === '' || $signatureHeader === '') {
        throw new RuntimeException('Missing webhook headers');
    }
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        throw new RuntimeException('Stale or invalid webhook-timestamp');
    }

    $key = base64_decode(preg_replace('/^whsec_/', '', $signingKey));
    $digest = hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true);
    $expected = 'v1,' . base64_encode($digest);

    foreach (explode(' ', $signatureHeader) as $signature) {
        if (hash_equals($expected, $signature)) {
            return json_decode($rawBody, true);
        }
    }
    throw new RuntimeException('Invalid webhook-signature');
}
```
:::

Call it with the raw body and the request headers, keyed by lowercase name. Node.js lowercases incoming header names, so `req.headers["webhook-id"]` works in Express. Flask's `request.headers` matches names in any case. In PHP, lowercase them with `array_change_key_case(getallheaders())`.

:::code Call the verifier
```js
app.post("/flownally/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verifyFlownallyWebhook(req.body, req.headers, process.env.FLOWNALLY_WEBHOOK_SIGNING_KEY);
  } catch {
    return res.sendStatus(401);
  }

  await enqueue(event);
  res.sendStatus(204);
});
```

```python
@app.post("/flownally/webhooks")
def flownally_webhooks():
    try:
        event = verify_flownally_webhook(
            request.get_data(), request.headers, os.environ["FLOWNALLY_WEBHOOK_SIGNING_KEY"]
        )
    except ValueError:
        return "", 401

    enqueue(event)
    return "", 204
```

```php
try {
    $event = verifyFlownallyWebhook(
        file_get_contents('php://input'),
        array_change_key_case(getallheaders()),
        getenv('FLOWNALLY_WEBHOOK_SIGNING_KEY'),
    );
} catch (RuntimeException) {
    http_response_code(401);
    exit;
}

enqueue($event);
http_response_code(204);
```
:::

## Reject stale webhook-timestamp values

A valid signature proves that Flownally sent the request, not that it was sent just now. Without a time check, an attacker can replay a captured request later.

**Reject deliveries whose `webhook-timestamp` is more than 5 minutes before or after your server's current time.**

The check doesn't reject legitimate retries. Flownally generates `webhook-timestamp` and `webhook-signature` again for each attempt.

The `standardwebhooks` libraries apply a 5-minute tolerance in both directions. The by-hand examples above do the same.

:::callout Keep your server clock in sync
Keep your server clock synchronized, for example with NTP, or valid deliveries fail this check.
:::

## Accept multiple signatures during signing key rotation

A receiver that expects exactly one signature rejects valid deliveries during a rotation.

After [`POST /webhooks/signing-keys:rotate`](/api/reference/webhooks#rotate-webhook-signing-key), the old key is `expiring` for 24 hours, and both keys sign each delivery. During that overlap, `webhook-signature` contains two signatures separated by a space, for example `v1,<signature with one key> v1,<signature with the other key>`.

Your receiver needs only one of the keys. Split the header on spaces and accept the delivery if any signature matches.

Don't assume the header holds a single value, and don't depend on the order. The libraries handle this for you.

Deploy the new key to your receiver before the 24 hours end. See [Rotate the signing key](/api/webhooks#rotate-the-signing-key) for the full flow.

## Deduplicate deliveries on webhook-id

Delivery is at least once, so the same event can arrive more than once. If you process both copies, your system acts twice, for example by sending a customer a duplicate message.

The `webhook-id` header equals the payload `id` and stays the same on every retry, and so does the body. **Use `webhook-id` as your idempotency key.**

1. Verify the signature first, so forged requests can't write to your dedupe store.
2. Record the `webhook-id` with a unique constraint, in the same transaction that stores or enqueues the event.
3. If the ID already exists, return a `2xx` status and skip the event.

:::code Skip duplicate deliveries
```js
app.post("/flownally/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.sendStatus(401);
  }

  // Inserts the event and returns false when its id is already stored.
  const isNew = await storeEventOnce(event.id, event);
  if (isNew) await enqueue(event.id);

  res.sendStatus(204);
});
```

```python
@app.post("/flownally/webhooks")
def flownally_webhooks():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        return "", 401

    # Inserts the event and returns False when its id is already stored.
    if store_event_once(event["id"], event):
        enqueue(event["id"])

    return "", 204
```

```php
try {
    $event = $webhook->verify(file_get_contents('php://input'), array_change_key_case(getallheaders()));
} catch (WebhookVerificationException) {
    http_response_code(401);
    exit;
}

// Inserts the event and returns false when its id is already stored.
if (storeEventOnce($event['id'], $event)) {
    enqueue($event['id']);
}

http_response_code(204);
```
:::

Deliveries can also arrive out of order, even within one conversation. For `message.updated`, keep the snapshot with the highest `message.revision`.

## Respond with 2xx fast and process events asynchronously

Each delivery attempt times out after 15 seconds, and Flownally retries timeouts. Any `2xx` status acknowledges the event.

**Verify → Store or enqueue → Respond → Process in a worker**

Do slow work, such as calling other APIs or a language model, from a background worker.

Choose the status code by what you want Flownally to do:

- **Return `2xx` once the event is stored.** Do this for duplicates and event types you ignore, too.
- **Return `401` for a missing or invalid signature.** Any `4xx` other than `408` and `429` stops retries for that event.
- **Return `5xx` when your own storage or queue fails.** Flownally retries timeouts, connection errors, `5xx`, `408` and `429` responses.

Redirects aren't followed, so point the webhook URL at the final address. See [Retries](/api/webhooks#retries) for the schedule.

## Test your receiver with POST /webhooks:verify

[`POST /webhooks:verify`](/api/reference/webhooks#verify-webhook) sends a signed `webhook.test` event to the saved webhook URL. To test another address, pass it as `url` in the body.

:::code Send a test event
```bash
curl -X POST "https://api.flownally.com/v1/webhooks:verify" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch("https://api.flownally.com/v1/webhooks:verify", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const data = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/webhooks:verify",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
)
response.raise_for_status()
data = response.json()
```

```php
$client = new GuzzleHttp\Client();

$response = $client->post('https://api.flownally.com/v1/webhooks:verify', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
]);
$data = json_decode((string) $response->getBody(), true);
```
:::

The test event uses the same headers and signature as real deliveries. Its body carries no business data:

```json
{
  "id": "whevt_…",
  "type": "webhook.test",
  "timestamp": "2026-04-30T09:20:00Z",
  "data": { "test": true }
}
```

Return a `2xx` status for it. The response reports `delivered`, the `statusCode` your endpoint returned, and an `error` when delivery failed.

Flownally delivers events only while the webhook is enabled and verified, and changing `url` clears `verified`.

## FAQ

### Why does every signature check fail?

The body you verify is usually not the raw body. Check that no JSON middleware runs before your webhook route and that you pass the bytes you received, not a re-serialized object. Then check that you verify with the current signing key.

### Why did verification start failing after a key rotation?

The old key expires 24 hours after rotation. Deploy the new `plaintextKey` from the rotation response to every instance of your receiver. `GET /webhooks/signing-keys` lists the `active` key and any `expiring` key with its `expiresAt`, plus `last4` to tell them apart.

### Why does my by-hand check fail when the library passes?

Base64-decode the part of the key after `whsec_` and use the decoded bytes as the HMAC key. Using the `whsec_…` string itself as the key produces a different signature. Also split `webhook-signature` on spaces before you compare.

### How do I see what my endpoint returned?

Call [`GET /webhooks/attempts`](/api/reference/webhooks#list-webhook-attempts) with `status=failed`. Each attempt shows `responseStatus`, `responseBody`, `errorText`, and `signedWithVersions`, the signing key versions used for that attempt. Compare them with `version` in `GET /webhooks/signing-keys`. Attempts are kept for 7 days.

### Will a retried delivery fail the timestamp check?

No. Flownally generates `webhook-timestamp` and `webhook-signature` again for each attempt, so a retry carries a fresh timestamp. Keep your server clock synchronized so valid deliveries pass the 5-minute tolerance.

## Related articles

- [Webhooks](/api/webhooks)
- [Build an AI reply agent](/api/ai-reply-agent)
- [Handle the WhatsApp 24-hour reply window](/api/reply-window)
- [Authentication](/api/authentication)
