NewThree filters returned the wrong rows
BeeL
Get StartedMulti-NIFVeriFactuStripeAPI ReferenceChangelog
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 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
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

POST /v1/accounts/{account_id}/webhooks/{webhook_id}/secretwebhooks: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_sk_live_xxx" \
  -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