Webhooks
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.
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);Rotating the Secret
If your secret is ever compromised, rotate it immediately:
curl -X POST "https://app.beel.es/api/v1/webhooks/{webhook_id}/secret" \
-H "Authorization: Bearer beel_sk_live_xxx"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