# 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.**

<Callout type="info">
  **Using the official SDK?** The [Node.js SDK](/sdks/node) ships a built-in `WebhookVerifier` that handles all the verification logic for you.
</Callout>

## The `BeeL-Signature` header

```http
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

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
   ```text
   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)

<Callout type="warn">
  Always read the **raw request body** (bytes) before parsing JSON. If you parse first and re-serialize, whitespace differences will break the signature.
</Callout>

---

## Code examples

### Node.js

```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

```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

```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](/webhooks/retries#retry-policy) and each [manual retry](/webhooks/retries#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](#rotating-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:

```bash
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)"
```

<Callout type="warn">
  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.
</Callout>

---

## 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

## Related

<Related>

- [Deduplication](/webhooks/deduplication) — the next check after the signature
- [Retries & delivery logs](/webhooks/retries) — what happens when you reject a delivery
- [Node.js SDK](/sdks/node) — built-in verification

</Related>

---

Full OpenAPI spec: https://docs.beel.es/api/openapi