Skip to main content

Overview

Webhook signature verification ensures that incoming webhook requests are genuinely from Fin and have not been tampered with. This process uses HMAC (Hash-based Message Authentication Code) to validate each webhook payload.

How It Works

  1. Extract the signature: Retrieve the signature value from the webhook request header
  2. Recalculate the signature: Use your secret key and the same hash algorithm to compute the HMAC signature for the received payload
  3. Compare signatures: Verify that your computed signature matches the one provided in the header

Verification Steps

Follow these steps to verify webhook signatures in your application:
  1. Read the raw request body - Capture the HTTP body as raw bytes without any modifications or parsing
  2. Extract signature headers - Read the x-fin-signature and x-fin-signature-algorithm headers from the request
  3. Compute the HMAC - Using your webhook secret key and the algorithm specified in x-fin-signature-algorithm, calculate:
    HMAC(algorithm, secretKey, rawBodyBytes) → hexadecimal string
    
  4. Compare signatures - Accept the webhook only if your computed signature exactly matches the x-fin-signature header value
Always use a constant-time comparison function to prevent timing attacks when comparing signatures.

Code Examples

Here are implementation examples in different programming languages:
const crypto = require('crypto');

function verifyWebhookSignature(request, webhookSecret) {
  // Extract headers
  const receivedSignature = request.headers['x-fin-signature'];
  const algorithm = request.headers['x-fin-signature-algorithm'] || 'sha256';
  
  // Get raw body as string or buffer
  const rawBody = request.body; // Ensure this is the raw body, not parsed JSON
  
  // Compute HMAC
  const computedSignature = crypto
    .createHmac(algorithm, webhookSecret)
    .update(rawBody, 'utf8')
    .digest('hex');
  
  // Compare signatures using constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(computedSignature)
  );
}

// Usage example with Express.js
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const isValid = verifyWebhookSignature(req, process.env.WEBHOOK_SECRET);
  
  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process the webhook
  const payload = JSON.parse(req.body);
  console.log('Valid webhook received:', payload);
  res.status(200).send('OK');
});
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "io"
    "net/http"
)

func verifyWebhookSignature(r *http.Request, webhookSecret string) (bool, error) {
    // Extract headers
    receivedSignature := r.Header.Get("x-fin-signature")
    algorithm := r.Header.Get("x-fin-signature-algorithm")
    if algorithm == "" {
        algorithm = "sha256"
    }
    
    // Read raw body
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
        return false, err
    }
    
    // Compute HMAC (using SHA-256 as example)
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write(rawBody)
    computedSignature := hex.EncodeToString(mac.Sum(nil))
    
    // Constant-time comparison
    return subtle.ConstantTimeCompare(
        []byte(receivedSignature),
        []byte(computedSignature),
    ) == 1, nil
}

// HTTP handler example
func webhookHandler(w http.ResponseWriter, r *http.Request) {
    webhookSecret := os.Getenv("WEBHOOK_SECRET")
    
    isValid, err := verifyWebhookSignature(r, webhookSecret)
    if err != nil || !isValid {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }
    
    // Process the webhook
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}
import hmac
import hashlib
from flask import Flask, request, abort

def verify_webhook_signature(request, webhook_secret):
    """
    Verify the HMAC signature of an incoming webhook request.
    
    Args:
        request: Flask request object
        webhook_secret: Your webhook secret key
        
    Returns:
        bool: True if signature is valid, False otherwise
    """
    # Extract headers
    received_signature = request.headers.get('x-fin-signature')
    algorithm = request.headers.get('x-fin-signature-algorithm', 'sha256')
    
    if not received_signature:
        return False
    
    # Get raw body bytes
    raw_body = request.get_data()
    
    # Compute HMAC
    hash_func = getattr(hashlib, algorithm)
    computed_signature = hmac.new(
        webhook_secret.encode('utf-8'),
        raw_body,
        hash_func
    ).hexdigest()
    
    # Constant-time comparison
    return hmac.compare_digest(received_signature, computed_signature)

# Flask example
app = Flask(__name__)

@app.route('/webhooks', methods=['POST'])
def webhook_handler():
    webhook_secret = os.environ.get('WEBHOOK_SECRET')
    
    if not verify_webhook_signature(request, webhook_secret):
        abort(401, 'Invalid signature')
    
    # Process the webhook
    payload = request.get_json()
    print(f'Valid webhook received: {payload}')
    
    return 'OK', 200
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;

public class WebhookVerifier {
    
    public static boolean verifyWebhookSignature(
        String receivedSignature,
        String algorithm,
        byte[] rawBody,
        String webhookSecret
    ) {
        try {
            // Default to SHA-256 if algorithm not specified
            if (algorithm == null || algorithm.isEmpty()) {
                algorithm = "sha256";
            }
            
            // Convert algorithm name to Java format (e.g., "sha256" -> "HmacSHA256")
            String javaAlgorithm = "Hmac" + algorithm.toUpperCase().replace("SHA", "SHA");
            
            // Compute HMAC
            Mac mac = Mac.getInstance(javaAlgorithm);
            SecretKeySpec secretKeySpec = new SecretKeySpec(
                webhookSecret.getBytes("UTF-8"),
                javaAlgorithm
            );
            mac.init(secretKeySpec);
            byte[] hmacBytes = mac.doFinal(rawBody);
            
            // Convert to hex string
            String computedSignature = HexFormat.of().formatHex(hmacBytes);
            
            // Constant-time comparison
            return MessageDigest.isEqual(
                receivedSignature.getBytes(),
                computedSignature.getBytes()
            );
            
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    
    // Spring Boot example
    @PostMapping("/webhooks")
    public ResponseEntity<String> handleWebhook(
        @RequestHeader("x-fin-signature") String signature,
        @RequestHeader(value = "x-fin-signature-algorithm", defaultValue = "sha256") String algorithm,
        @RequestBody byte[] rawBody
    ) {
        String webhookSecret = System.getenv("WEBHOOK_SECRET");
        
        boolean isValid = verifyWebhookSignature(signature, algorithm, rawBody, webhookSecret);
        
        if (!isValid) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
        }
        
        // Process the webhook
        String payload = new String(rawBody, StandardCharsets.UTF_8);
        System.out.println("Valid webhook received: " + payload);
        
        return ResponseEntity.ok("OK");
    }
}

Best Practices

Never hardcode your webhook secret in your source code. Use environment variables or a secure secrets management system.
Always use constant-time comparison functions (like crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python) to prevent timing attacks.
Ensure your web framework provides access to the raw request body before any parsing or modifications occur.
Return appropriate HTTP status codes (401 Unauthorized) for failed verifications without revealing specific error details.

Troubleshooting

If signature verification is failing:
  • Check the raw body: Ensure you’re using the exact raw bytes received, not a re-serialized version
  • Verify the secret: Confirm you’re using the correct webhook secret from your Fin dashboard
  • Check character encoding: Make sure you’re using UTF-8 encoding consistently
  • Inspect headers: Verify that x-fin-signature and x-fin-signature-algorithm headers are present
  • Test the algorithm: Confirm you’re using the algorithm specified in the x-fin-signature-algorithm header