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.

Using an official SDK? The Node.js, Java, and Python SDKs include built-in WebhookVerifier utilities that handle all the verification logic for you. Skip to the SDK documentation for ready-to-use code.

The BeeL-Signature Header

BeeL-Signature: t=1741362026,v1=3c4f7a2e1b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f
ComponentDescription
tUnix timestamp (seconds) when the signature was generated
v1HMAC-SHA256 hex digest of the signed payload

How to Verify

Step-by-step

  1. Extract t and v1 from the header by splitting on , and then =
  2. Build the signed string: concatenate t, a literal ., and the raw request body
    signed_string = t + "." + raw_body
  3. Compute HMAC-SHA256(signed_string, secret) where secret is the webhook secret you received on creation
  4. Compare your computed digest with v1 using a constant-time comparison (to prevent timing attacks)
  5. Check the timestamp: reject if |current_time - t| > 300 seconds (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