FlaresendDocs

Verify signatures

Check that a webhook request really came from your Flaresend mailer and was not changed on the way.

Anyone who knows your webhook URL can send it a request. Every real request from Flaresend is signed with the webhook's secret, so check the signature before you trust the body.

Request headers

Every delivery is a POST with these headers:

HeaderValue
Content-Typeapplication/json
User-AgentFlaresend-Webhooks/1.0
Flaresend-Signaturet=<unix seconds>,v1=<hex signature>
Flaresend-Event-IdThe event ID, the same as id in the body
Flaresend-Delivery-IdThe ID of this delivery (whd_…). A retry of the same delivery keeps the same ID

The algorithm

Flaresend-Signature: t=1758895331,v1=5f2b0c1e...9a
  • t is the time the request was signed, in Unix seconds. It is set again on every attempt.
  • v1 is the lowercase hex HMAC-SHA256 of the string {t}.{raw body}.
  • The HMAC key is the whole secret string as UTF-8 bytes, including the whsec_ prefix. Don't base64-decode it or strip the prefix.

To verify:

  1. Split the header on , and each part on the first =. Take t and every v1.
  2. Reject the request if t is more than 5 minutes (300 seconds) away from your clock. This stops someone replaying an old request.
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) as hex.
  4. Accept if it equals any v1. Compare in constant time.

Use the raw body

Sign-check the exact bytes you received. Parsing the JSON and serializing it again changes spacing and key order, and the signature will not match. In Express, use express.raw() on the webhook route, not express.json(). In Next.js and Workers, use await req.text().

With the SDK

verifyWebhookSignature does all four steps. It uses Web Crypto, so it runs in Node.js 18+, Bun, Deno, browsers and Workers. It returns a Promise<boolean> and never throws on bad input.

import { verifyWebhookSignature } from '@flaresend/client/webhooks';

const raw = await req.text();
const ok = await verifyWebhookSignature(
  process.env.FLARESEND_WEBHOOK_SECRET!,
  req.headers.get('Flaresend-Signature'),
  raw,
  300, // tolerance in seconds; this is the default
);
if (!ok) return new Response('bad signature', { status: 401 });

It returns false when the header is missing or malformed, when no v1 matches, or when t is further than the tolerance from now. rawBody can be a string, an ArrayBuffer or a Uint8Array (a Node.js Buffer works). See SDK: webhooks.

Without the SDK

import hashlib
import hmac
import time


def verify_flaresend_signature(secret: str, header: str | None, raw_body: bytes, tolerance: int = 300) -> bool:
    if not secret or not header:
        return False
    timestamp = None
    candidates = []
    for part in header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            if not value.isdigit():
                return False
            timestamp = int(value)
        elif key == "v1":
            candidates.append(value.lower())
    if timestamp is None or not candidates:
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    message = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, c) for c in candidates)


# Flask
@app.post("/api/flaresend-webhook")
def flaresend_webhook():
    raw = request.get_data()  # bytes, before any JSON parsing
    if not verify_flaresend_signature(os.environ["FLARESEND_WEBHOOK_SECRET"], request.headers.get("Flaresend-Signature"), raw):
        return "bad signature", 401
    event = json.loads(raw)
    return "ok"

Rotating the secret

Rotate a secret when it may have leaked, from the webhook's page in the dashboard (Rotate secret) or with POST /v1/webhooks/:id/rotate-secret. The response carries the new secret once.

The old secret stops working at once: every delivery after the rotation, including retries of older events, is signed with the new secret only. To avoid rejecting events while you deploy the new secret, have your endpoint accept either secret for a short while:

const ok =
  (await verifyWebhookSignature(env.FLARESEND_WEBHOOK_SECRET, header, raw)) ||
  (await verifyWebhookSignature(env.FLARESEND_WEBHOOK_SECRET_OLD, header, raw));

Events your endpoint rejected during the switch are retried on the normal schedule, so most of them arrive again once the new secret is live.

More than one v1

The header format allows several v1= values, and verifyWebhookSignature accepts a match on any of them. The mailer currently sends exactly one. Write your own verifier to accept any match, as the examples above do, so it keeps working if that changes.

Testing your verifier

signWebhookPayload builds a header the same way the mailer does. Use it in tests:

import { signWebhookPayload, verifyWebhookSignature } from '@flaresend/client/webhooks';

const body = JSON.stringify({ id: 'evt_test', type: 'email.delivered', createdAt: new Date().toISOString(), data: {} });
const header = await signWebhookPayload('whsec_test', body);

await verifyWebhookSignature('whsec_test', header, body); // true
await verifyWebhookSignature('whsec_other', header, body); // false

On this page