Signature verification
Verify that webhook requests genuinely come from BeeL. using HMAC-SHA256.
Every webhook delivery includes a BeeL-Signature header that lets you verify the request genuinely came from BeeL. and was not tampered with. Always verify signatures in production.
Using the official SDK? The Node.js SDK ships a built-in WebhookVerifier that handles all the verification logic for you.
The BeeL-Signature header
BeeL-Signature: t=1741362026,v1=3c4f7a2e1b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f| Component | Description |
|---|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | HMAC-SHA256 hex digest of the signed payload |
How to verify
Step-by-step
- Extract
tandv1from the header by splitting on,and then= - Build the signed string: concatenate
t, a literal., and the raw request bodysigned_string = t + "." + raw_body - Compute
HMAC-SHA256(signed_string, secret)wheresecretis the webhook secret you received on creation - Compare your computed digest with
v1using a constant-time comparison (to prevent timing attacks) - Check the timestamp: reject if
|current_time - t| > 300seconds (5-minute replay window)
Always read the raw request body (bytes) before parsing JSON. If you parse first and re-serialize, whitespace differences will break the signature.
Code examples
Node.js
import crypto from 'crypto';
function verifyBeelSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(part => part.split('=', 2))
);
const timestamp = parts['t'];
const v1 = parts['v1'];
if (!timestamp || !v1) return false;
// Replay protection: reject requests older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
const signedString = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedString, 'utf8')
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex')
);
}
// Express.js usage — read raw body BEFORE parsing
app.post('/webhooks/beel', express.raw({ type: 'application/json' }), (req, res) => {
const isValid = verifyBeelSignature(
req.body, // Buffer (raw bytes)
req.headers['beel-signature'],
process.env.BEEL_WEBHOOK_SECRET
);
if (!isValid) return res.status(401).send('Unauthorized');
const event = JSON.parse(req.body.toString());
// handle event...
res.status(200).send('OK');
});Python
import hashlib
import hmac
import time
def verify_beel_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in signature_header.split(","))
timestamp = parts.get("t")
v1 = parts.get("v1")
if not timestamp or not v1:
return False
# Replay protection
if abs(time.time() - int(timestamp)) > 300:
return False
signed_string = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode("utf-8"),
signed_string.encode("utf-8"),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, v1)
# FastAPI / Starlette usage
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhooks/beel")
async def webhook(request: Request):
raw_body = await request.body()
signature = request.headers.get("BeeL-Signature", "")
if not verify_beel_signature(raw_body, signature, BEEL_WEBHOOK_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
# handle event...
return {"ok": True}PHP
function verifyBeelSignature(string $rawBody, string $signatureHeader, string $secret): bool {
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$key, $value] = explode('=', $part, 2);
$parts[$key] = $value;
}
$timestamp = $parts['t'] ?? null;
$v1 = $parts['v1'] ?? null;
if (!$timestamp || !$v1) return false;
// Replay protection
if (abs(time() - (int)$timestamp) > 300) return false;
$signedString = "{$timestamp}.{$rawBody}";
$expected = hash_hmac('sha256', $signedString, $secret);
return hash_equals($expected, $v1);
}
$rawBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_BEEL_SIGNATURE'] ?? '';
if (!verifyBeelSignature($rawBody, $signatureHeader, getenv('BEEL_WEBHOOK_SECRET'))) {
http_response_code(401);
exit('Unauthorized');
}
$event = json_decode($rawBody, true);
// handle event...
http_response_code(200);Every attempt is signed afresh
The signature is computed when each attempt is sent, not when the event happens. Every attempt — the first, each automatic retry and each manual retry — carries a new t with the current time and a new v1 computed over it, together with a new BeeL-Delivery-Id. The BeeL-Event-Id stays the same across all of them.
So the 5-minute replay window never rejects a legitimate retry, however late it arrives: check t on every request, with no exception for retries.
Each attempt is signed with the subscription's current secret. After you rotate the secret, retries of events that happened before the rotation are signed with the new one.
Rotating the secret
POST /v1/accounts/{account_id}/webhooks/{webhook_id}/secret — webhooks:write. If your secret is ever
compromised, rotate it immediately:
curl -X POST "https://app.beel.es/api/v1/accounts/{account_id}/webhooks/3f7a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c/secret" \
-H "Authorization: Bearer $BEEL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"The old secret is immediately invalidated when you rotate. Update your verification logic before rotating to avoid a gap in coverage. The new secret is only returned once in this response — store it securely.
Security checklist
- Verify the signature on every incoming request
- Use constant-time comparison (avoid string
==/===) - Reject requests where
|now - t| > 300s - Read the raw body bytes — do not re-serialize before computing the HMAC
- Store the webhook secret in an environment variable, never in code
- Rotate the secret immediately if it is ever exposed