Skip to main content

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

HeaderExampleDescription
X-Webhook-SignatureK7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=Base64-encoded HMAC-SHA256 signature
X-Webhook-Timestamp1713700800000Unix timestamp in milliseconds when the delivery was sent

Verification example

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)
  );
}

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.
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:
PhaseDescription
PendingNew secret created but not yet active. Deliveries are still signed with the active secret. The pending secret expires after 24 hours if not activated.
ActiveThe current signing secret. All new deliveries are signed with this secret.
ExpiredThe 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:
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
Only one pending secret is allowed at a time. You must activate or let it expire before creating another.

Common mistakes

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.
The signature is Base64-encoded, not hex. Make sure your HMAC output is encoded as Base64 before comparing.
Using === instead of timingSafeEqual (Node.js) or hmac.compare_digest (Python) leaks timing information that can be exploited to forge signatures.