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.
Security headers
Section titled “Security headers”| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature of the request |
X-Webhook-Timestamp | Unix timestamp when the request was sent |
How signature verification works
Section titled “How signature verification works”The signature is generated by:
- Combining the timestamp and raw request body with a dot (
.) separator - 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.
Security best practices
Section titled “Security best practices”- Verify the webhook signature as soon as possible using HMAC-SHA256
- Ensure the timestamp is within a 5-minute window to prevent replay attacks
- Use constant-time comparison when checking signatures
- Return 200 OK immediately after verification
- Process the webhook payload asynchronously after responding
- Keep your webhook secret key safe and never commit it to source control
Verification examples
Section titled “Verification examples”Python
Section titled “Python”import hmacimport hashlibimport timefrom fastapi import Request, HTTPException
WEBHOOK_SECRET = "whsec_..." # Load from environment variablesMAX_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 codeJavaScript
Section titled “JavaScript”const crypto = require("crypto");
const WEBHOOK_SECRET = "whsec_..."; // Load from environment variablesconst 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) endendpackage 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}Rotating your signing secret
Section titled “Rotating your signing secret”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:
- Go to Settings → Webhooks
- Select the webhook you want to rotate
- Click the Rotate Key button
Via the API:
Send a POST request to refresh the signing secret:
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.