To verify webhook authenticity, each request includes security headers that allow you to validate the request came from Featurebase.
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature of the request |
X-Webhook-Timestamp | Unix timestamp when the request was sent |
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.
- 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
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 codeconst 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
class WebhookSignatureVerifier
{
private const WEBHOOK_SECRET = 'whsec_...'; // Load from environment
private const MAX_TIMESTAMP_DIFF = 300; // 5 minutes
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');
}
// Verify timestamp freshness
$timestampDiff = abs(time() - (int)$timestamp);
if ($timestampDiff > 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
);
}
}# 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@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);
}
}
}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
}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<IActionResult> 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 });
}
}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 (Nova only):
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.