Skip to content
Last updated

Webhook security

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

Security headers

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature of the request
X-Webhook-TimestampUnix 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:
        # 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

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

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
        );
    }
}

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

@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

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#

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 });
    }
}