Skip to content
Dashboard
Webhooks

Security

Verifying Featurebase webhook signatures with HMAC-SHA256.

To verify webhook authenticity, each request includes security headers that allow you to validate the request came from Featurebase.

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature of the request
X-Webhook-TimestampUnix timestamp when the request was sent

The signature is generated by:

  1. Combining the timestamp and raw request body with a dot (.) separator
  2. Creating an HMAC-SHA256 hash using your webhook secret

Your webhook secret starts with whsec_ and can be found in your webhook settings or via the Webhooks API.

  1. Verify the webhook signature as soon as possible using HMAC-SHA256
  2. Ensure the timestamp is within a 5-minute window to prevent replay attacks
  3. Use constant-time comparison when checking signatures
  4. Return 200 OK immediately after verification
  5. Process the webhook payload asynchronously after responding
  6. Keep your webhook secret key safe and never commit it to source control

import hmac
import hashlib
import time
from fastapi import Request, HTTPException
WEBHOOK_SECRET = "whsec_..." # Load from environment variables
MAX_TIMESTAMP_DIFF = 300 # 5 minutes
async def verify_webhook_signature(request: Request) -> None:
"""Verify the webhook signature or raise HTTPException."""
try:
signature = request.headers.get("X-Webhook-Signature")
timestamp = request.headers.get("X-Webhook-Timestamp")
if not signature or not timestamp:
raise HTTPException(401, "Missing signature headers")
timestamp_diff = abs(int(time.time()) - int(timestamp))
if timestamp_diff > MAX_TIMESTAMP_DIFF:
raise HTTPException(401, "Webhook timestamp too old")
body = await request.body()
signed_payload = f"{timestamp}.{body.decode('utf-8')}"
expected = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(401, "Invalid signature")
except ValueError:
raise HTTPException(401, "Invalid signature format")
# Usage in webhook handler:
@app.post("/webhooks/featurebase")
async def handle_webhook(request: Request):
await verify_webhook_signature(request)
# ... rest of handler code

const crypto = require("crypto");
const WEBHOOK_SECRET = "whsec_..."; // Load from environment variables
const MAX_TIMESTAMP_DIFF = 300; // 5 minutes
function verifyWebhookSignature(req) {
const signature = req.headers["x-webhook-signature"];
const timestamp = req.headers["x-webhook-timestamp"];
if (!signature || !timestamp) {
throw new Error("Missing signature headers");
}
const timestampDiff = Math.abs(
Math.floor(Date.now() / 1000) - parseInt(timestamp)
);
if (timestampDiff > MAX_TIMESTAMP_DIFF) {
throw new Error("Webhook timestamp too old");
}
const signedPayload = `${timestamp}.${req.rawBody}`;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(expected, "utf8"),
Buffer.from(signature, "utf8")
)
) {
throw new Error("Invalid signature");
}
}
// Usage in webhook handler:
router.post(
"/webhooks/featurebase",
express.raw({ type: "application/json" }),
(req, res) => {
try {
verifyWebhookSignature(req);
// ... rest of handler code
} catch (error) {
return res.status(401).json({ error: error.message });
}
}
);

<?php
class WebhookSignatureVerifier
{
private const WEBHOOK_SECRET = 'whsec_...';
private const MAX_TIMESTAMP_DIFF = 300;
public static function verify(string $rawBody, array $headers): void
{
$signature = $headers['X-Webhook-Signature'] ?? null;
$timestamp = $headers['X-Webhook-Timestamp'] ?? null;
if (!$signature || !$timestamp) {
throw new InvalidArgumentException('Missing signature headers');
}
$timestampDiff = abs(time() - (int)$timestamp);
if ($timestampDiff > self::MAX_TIMESTAMP_DIFF) {
throw new InvalidArgumentException('Webhook timestamp too old');
}
$signedPayload = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, self::WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
throw new InvalidArgumentException('Invalid signature');
}
}
}

class WebhookSignatureVerifier
WEBHOOK_SECRET = ENV.fetch('WEBHOOK_SECRET')
MAX_TIMESTAMP_DIFF = 300
class InvalidSignature < StandardError; end
def self.verify!(raw_body:, headers:)
new(raw_body: raw_body, headers: headers).verify!
end
def initialize(raw_body:, headers:)
@raw_body = raw_body
@signature = headers['X-Webhook-Signature']
@timestamp = headers['X-Webhook-Timestamp']
end
def verify!
raise InvalidSignature, 'Missing signature headers' unless @signature && @timestamp
timestamp_diff = (Time.now.to_i - @timestamp.to_i).abs
raise InvalidSignature, 'Webhook timestamp too old' if timestamp_diff > MAX_TIMESTAMP_DIFF
signed_payload = "#{@timestamp}.#{@raw_body}"
expected = OpenSSL::HMAC.hexdigest('SHA256', WEBHOOK_SECRET, signed_payload)
raise InvalidSignature, 'Invalid signature' unless Rack::Utils.secure_compare(expected, @signature)
end
end

package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"time"
)
const (
webhookSecret = "whsec_..."
maxTimestampDiff = 300
)
type SignatureError struct {
Message string
}
func (e *SignatureError) Error() string { return e.Message }
func VerifyWebhookSignature(r *http.Request) error {
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")
if signature == "" || timestamp == "" {
return &SignatureError{"Missing signature headers"}
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return &SignatureError{"Invalid timestamp format"}
}
if abs(time.Now().Unix()-ts) > maxTimestampDiff {
return &SignatureError{"Webhook timestamp too old"}
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("error reading request body: %v", err)
}
r.Body.Close()
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(body))
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
expected := hex.EncodeToString(mac.Sum(nil))
if subtle.ConstantTimeCompare([]byte(expected), []byte(signature)) != 1 {
return &SignatureError{"Invalid signature"}
}
return nil
}

If your webhook secret is compromised or you want to rotate it as a security precaution, you can generate a new one at any time. The previous secret is immediately invalidated once rotated.

From the dashboard:

  1. Go to Settings → Webhooks
  2. Select the webhook you want to rotate
  3. Click the Rotate Key button

Via the API:

Send a POST request to refresh the signing secret:

Terminal window
curl -X POST https://do.featurebase.app/v2/webhooks/{id}/secret \
-H "Authorization: Bearer sk_..." \
-H "Featurebase-Version: 2026-01-01.nova"

The response returns the updated webhook object with the new secret field.

Important: After rotating, any integrations verifying webhook signatures with the old secret will reject incoming payloads until you update them with the new secret. Plan to update your verification code immediately after rotation.