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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Flaresend-Webhooks/1.0 |
Flaresend-Signature | t=<unix seconds>,v1=<hex signature> |
Flaresend-Event-Id | The event ID, the same as id in the body |
Flaresend-Delivery-Id | The ID of this delivery (whd_…). A retry of the same delivery keeps the same ID |
The algorithm
Flaresend-Signature: t=1758895331,v1=5f2b0c1e...9atis the time the request was signed, in Unix seconds. It is set again on every attempt.v1is 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:
- Split the header on
,and each part on the first=. Taketand everyv1. - Reject the request if
tis more than 5 minutes (300 seconds) away from your clock. This stops someone replaying an old request. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as hex. - 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