# Webhook security 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 ```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: # Get signature and timestamp 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") # Verify timestamp freshness timestamp_diff = abs(int(time.time()) - int(timestamp)) if timestamp_diff > MAX_TIMESTAMP_DIFF: raise HTTPException(401, "Webhook timestamp too old") # Get raw body body = await request.body() # Create signed payload signed_payload = f"{timestamp}.{body.decode('utf-8')}" # Calculate expected signature expected = hmac.new( WEBHOOK_SECRET.encode('utf-8'), signed_payload.encode('utf-8'), hashlib.sha256 ).hexdigest() # Secure comparison 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 ```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"); } // Verify timestamp freshness const timestampDiff = Math.abs( Math.floor(Date.now() / 1000) - parseInt(timestamp) ); if (timestampDiff > MAX_TIMESTAMP_DIFF) { throw new Error("Webhook timestamp too old"); } // Create signed payload const signedPayload = `${timestamp}.${req.rawBody}`; // Calculate expected signature const expected = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(signedPayload) .digest("hex"); // Secure comparison 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 ```php self::MAX_TIMESTAMP_DIFF) { throw new InvalidArgumentException('Webhook timestamp too old'); } // Create signed payload $signedPayload = $timestamp . '.' . $rawBody; // Calculate expected signature $expected = hash_hmac( 'sha256', $signedPayload, self::WEBHOOK_SECRET ); // Secure comparison if (!hash_equals($expected, $signature)) { throw new InvalidArgumentException('Invalid signature'); } } } // Usage in webhook handler: public function handle(Request $request) { try { WebhookSignatureVerifier::verify( $request->getContent(), $request->headers->all() ); // ... rest of handler code } catch (InvalidArgumentException $e) { return response()->json( ['error' => $e->getMessage()], 401 ); } } ``` ### Ruby ```ruby # app/services/webhook_signature_verifier.rb class WebhookSignatureVerifier WEBHOOK_SECRET = ENV.fetch('WEBHOOK_SECRET') MAX_TIMESTAMP_DIFF = 300 # 5 minutes 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! validate_presence! validate_timestamp! validate_signature! end private def validate_presence! unless @signature && @timestamp raise InvalidSignature, 'Missing signature headers' end end def validate_timestamp! timestamp_diff = (Time.now.to_i - @timestamp.to_i).abs if timestamp_diff > MAX_TIMESTAMP_DIFF raise InvalidSignature, 'Webhook timestamp too old' end end def validate_signature! signed_payload = "#{@timestamp}.#{@raw_body}" expected = OpenSSL::HMAC.hexdigest( 'SHA256', WEBHOOK_SECRET, signed_payload ) unless Rack::Utils.secure_compare(expected, @signature) raise InvalidSignature, 'Invalid signature' end end end # Usage in webhook handler: def handle WebhookSignatureVerifier.verify!( raw_body: request.raw_post, headers: request.headers ) # ... rest of handler code rescue WebhookSignatureVerifier::InvalidSignature => e render json: { error: e.message }, status: :unauthorized end ``` ### Java ```java @Component public class WebhookSignatureVerifier { private static final String WEBHOOK_SECRET = "whsec_..."; // Load from config private static final int MAX_TIMESTAMP_DIFF = 300; // 5 minutes public void verify(String rawBody, HttpHeaders headers) throws WebhookSignatureException { String signature = headers.getFirst("X-Webhook-Signature"); String timestamp = headers.getFirst("X-Webhook-Timestamp"); if (signature == null || timestamp == null) { throw new WebhookSignatureException("Missing signature headers"); } // Verify timestamp freshness long timestampDiff = Math.abs( Instant.now().getEpochSecond() - Long.parseLong(timestamp) ); if (timestampDiff > MAX_TIMESTAMP_DIFF) { throw new WebhookSignatureException("Webhook timestamp too old"); } // Create signed payload String signedPayload = timestamp + "." + rawBody; // Calculate expected signature String expected = calculateHmac(signedPayload); // Secure comparison if (!MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8) )) { throw new WebhookSignatureException("Invalid signature"); } } private String calculateHmac(String payload) { try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKey = new SecretKeySpec( WEBHOOK_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256" ); mac.init(secretKey); byte[] hmacData = mac.doFinal( payload.getBytes(StandardCharsets.UTF_8) ); return Hex.encodeHexString(hmacData); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new WebhookSignatureException("Error calculating signature", e); } } } ``` ### Go ```go package webhooks import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "io/ioutil" "net/http" "strconv" "time" ) const ( webhookSecret = "whsec_..." // Load from config maxTimestampDiff = 300 // 5 minutes ) type SignatureError struct { Message string } func (e *SignatureError) Error() string { return e.Message } func VerifyWebhookSignature(r *http.Request) error { // Get headers signature := r.Header.Get("X-Webhook-Signature") timestamp := r.Header.Get("X-Webhook-Timestamp") if signature == "" || timestamp == "" { return &SignatureError{"Missing signature headers"} } // Verify timestamp freshness ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return &SignatureError{"Invalid timestamp format"} } timestampDiff := time.Now().Unix() - ts if abs(timestampDiff) > maxTimestampDiff { return &SignatureError{"Webhook timestamp too old"} } // Read body body, err := ioutil.ReadAll(r.Body) if err != nil { return fmt.Errorf("error reading request body: %v", err) } r.Body.Close() // Create signed payload signedPayload := fmt.Sprintf("%s.%s", timestamp, string(body)) // Calculate expected signature mac := hmac.New(sha256.New, []byte(webhookSecret)) mac.Write([]byte(signedPayload)) expected := hex.EncodeToString(mac.Sum(nil)) // Secure comparison if subtle.ConstantTimeCompare( []byte(expected), []byte(signature), ) != 1 { return &SignatureError{"Invalid signature"} } return nil } // Usage in webhook handler: func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { if err := VerifyWebhookSignature(r); err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } // ... rest of handler code } ``` ### C# ```csharp public class WebhookSignatureVerifier { private const string WebhookSecret = "whsec_..."; // Load from config private const int MaxTimestampDiff = 300; // 5 minutes public static void Verify(string rawBody, IHeaderDictionary headers) { var signature = headers["X-Webhook-Signature"].ToString(); var timestamp = headers["X-Webhook-Timestamp"].ToString(); if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp)) { throw new WebhookSignatureException("Missing signature headers"); } // Verify timestamp freshness if (!long.TryParse(timestamp, out var ts)) { throw new WebhookSignatureException("Invalid timestamp format"); } var timestampDiff = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts); if (timestampDiff > MaxTimestampDiff) { throw new WebhookSignatureException("Webhook timestamp too old"); } // Create signed payload var signedPayload = $"{timestamp}.{rawBody}"; // Calculate expected signature var expected = CalculateHmac(signedPayload); // Secure comparison if (!CryptographicOperations.FixedTimeEquals( Convert.FromHexString(expected), Convert.FromHexString(signature))) { throw new WebhookSignatureException("Invalid signature"); } } private static string CalculateHmac(string payload) { using var hmac = new HMACSHA256( Encoding.UTF8.GetBytes(WebhookSecret) ); var hash = hmac.ComputeHash( Encoding.UTF8.GetBytes(payload) ); return Convert.ToHexString(hash).ToLower(); } } // Usage in webhook handler: [HttpPost("featurebase")] public async Task HandleWebhook() { try { using var reader = new StreamReader(Request.Body); var rawBody = await reader.ReadToEndAsync(); WebhookSignatureVerifier.Verify(rawBody, Request.Headers); // ... rest of handler code } catch (WebhookSignatureException ex) { return Unauthorized(new { error = ex.Message }); } } ```