--- title: Security | Featurebase description: 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 | 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 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. ## Security best practices 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 --- ## Verification examples ### Python ``` 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 ``` --- ### JavaScript ``` 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 ``` 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'); } } } ``` --- ### Ruby ``` 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 ``` --- ### Go ``` 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 } ``` --- ## 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:** 1. Go to [Settings → Webhooks](https://auth.featurebase.app/login?redirect=/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.