> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bluvo.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify Signatures

> Validate webhook authenticity using HMAC-SHA256 signatures

## Why verify

Webhook endpoints are public URLs. Without verification, anyone who discovers your endpoint URL could send fake events. Signature verification proves that a delivery genuinely came from Bluvo.

## How it works

Every webhook delivery is signed with your endpoint's secret using **HMAC-SHA256**:

1. Bluvo concatenates the timestamp and JSON payload: `${timestamp}\n${payload}`
2. Computes the HMAC-SHA256 of that string using your webhook secret
3. Base64-encodes the result and sends it in the `X-Webhook-Signature` header

## Headers

| Header                | Example                                        | Description                                               |
| --------------------- | ---------------------------------------------- | --------------------------------------------------------- |
| `X-Webhook-Signature` | `K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=` | Base64-encoded HMAC-SHA256 signature                      |
| `X-Webhook-Timestamp` | `1713700800000`                                | Unix timestamp in milliseconds when the delivery was sent |

## Verification example

<CodeGroup>
  ```typescript Node.js / TypeScript theme={null}
  import crypto from "node:crypto";

  function verifyWebhookSignature(
    rawBody: string | Buffer,
    signature: string,
    timestamp: string,
    secret: string
  ): boolean {
    // 1. Reject stale deliveries (replay protection)
    const now = Date.now();
    const deliveryTime = parseInt(timestamp, 10);
    const fiveMinutes = 5 * 60 * 1000;

    if (Math.abs(now - deliveryTime) > fiveMinutes) {
      return false;
    }

    // 2. Compute expected signature
    const payload = typeof rawBody === "string" ? rawBody : rawBody.toString();
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}\n${payload}`)
      .digest("base64");

    // 3. Constant-time comparison
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import base64
  import time

  def verify_webhook_signature(
      raw_body: bytes,
      signature: str,
      timestamp: str,
      secret: str,
  ) -> bool:
      # 1. Reject stale deliveries (replay protection)
      now_ms = int(time.time() * 1000)
      delivery_time = int(timestamp)
      five_minutes = 5 * 60 * 1000

      if abs(now_ms - delivery_time) > five_minutes:
          return False

      # 2. Compute expected signature
      message = f"{timestamp}\n{raw_body.decode()}"
      expected = base64.b64encode(
          hmac.new(
              secret.encode(),
              message.encode(),
              hashlib.sha256,
          ).digest()
      ).decode()

      # 3. Constant-time comparison
      return hmac.compare_digest(signature, expected)
  ```
</CodeGroup>

## Timestamp validation

Bluvo does not enforce a maximum age on deliveries. We recommend your receiver rejects any payload where `X-Webhook-Timestamp` is more than **5 minutes** from the current time. This prevents replay attacks where an attacker captures a valid delivery and resends it later.

```typescript theme={null}
const fiveMinutes = 5 * 60 * 1000;
if (Math.abs(Date.now() - parseInt(timestamp, 10)) > fiveMinutes) {
  return res.status(401).send("Stale delivery");
}
```

## Secret rotation

Bluvo supports zero-downtime secret rotation with a 3-phase lifecycle:

| Phase       | Description                                                                                                                                            |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Pending** | New secret created but not yet active. Deliveries are still signed with the active secret. The pending secret expires after 24 hours if not activated. |
| **Active**  | The current signing secret. All new deliveries are signed with this secret.                                                                            |
| **Expired** | The previous active secret. Kept for a grace period so in-flight deliveries can still be verified. The last 2 expired secrets are retained.            |

During rotation, verify against both the new and old secrets:

```typescript theme={null}
function verifyWithRotation(
  rawBody: string,
  signature: string,
  timestamp: string,
  secrets: string[]
): boolean {
  return secrets.some((secret) =>
    verifyWebhookSignature(rawBody, signature, timestamp, secret)
  );
}
```

To rotate a secret in the Portal:

1. Navigate to **Settings > Webhooks** and select your endpoint
2. Click **Rotate Secret** — this creates a new pending secret
3. Update your server with the new secret
4. Click **Activate Secret** to promote it to active

<Warning>
  Only one pending secret is allowed at a time. You must activate or let it expire before creating another.
</Warning>

## Common mistakes

<AccordionGroup>
  <Accordion title="Parsed body instead of raw body">
    If you parse the JSON body before computing the signature, the re-serialized string may differ from the original (key ordering, whitespace). Always use the raw request body bytes.
  </Accordion>

  <Accordion title="Wrong encoding">
    The signature is **Base64-encoded**, not hex. Make sure your HMAC output is encoded as Base64 before comparing.
  </Accordion>

  <Accordion title="String comparison instead of constant-time">
    Using `===` instead of `timingSafeEqual` (Node.js) or `hmac.compare_digest` (Python) leaks timing information that can be exploited to forge signatures.
  </Accordion>
</AccordionGroup>
