Skip to content

Webhook Signature Verification

Hookbase signs all outbound webhook payloads using HMAC-SHA256 to ensure the integrity and authenticity of webhook deliveries. This guide explains how to verify webhook signatures in your application.

Signature Format

Every webhook request from Hookbase includes three signature headers:

HeaderDescription
x-hookbase-idUnique message identifier (e.g., wh_msg_abc123)
x-hookbase-timestampUnix timestamp (seconds) when the webhook was sent
x-hookbase-signatureThe signature in format v1,{base64_signature}

How Signatures Are Computed

Hookbase computes signatures using the following algorithm:

  1. Create the signing string: Concatenate the message ID, timestamp, and raw JSON payload with periods:

    {message_id}.{timestamp}.{payload}
  2. Decode the secret: Remove the whsec_ prefix from your signing secret and decode from hex to bytes

  3. Compute HMAC-SHA256: Sign the signing string using the decoded secret

  4. Base64 encode: Encode the resulting signature as base64

  5. Format the header: Prefix with version identifier: v1,{base64_signature}

Verification Steps

To verify a webhook signature:

  1. Extract the timestamp and signature from the headers
  2. Verify the timestamp is within your tolerance window (recommended: 5 minutes)
  3. Construct the signing string using the message ID, timestamp, and raw request body
  4. Compute your own signature using your signing secret
  5. Compare signatures using a timing-safe comparison function

Code Examples

Node.js

javascript
const crypto = require('crypto');

function verifyWebhookSignature(payload, headers, secret, toleranceSeconds = 300) {
  const messageId = headers['x-hookbase-id'];
  const timestamp = headers['x-hookbase-timestamp'];
  const providedSignature = headers['x-hookbase-signature'];

  // 1. Verify timestamp freshness
  const now = Math.floor(Date.now() / 1000);
  const webhookTimestamp = parseInt(timestamp, 10);

  if (Math.abs(now - webhookTimestamp) > toleranceSeconds) {
    throw new Error('Webhook timestamp is too old or too far in the future');
  }

  // 2. Extract signature (remove 'v1,' prefix)
  const parts = providedSignature.split(',');
  if (parts.length !== 2 || parts[0] !== 'v1') {
    throw new Error('Invalid signature format');
  }
  const signature = parts[1];

  // 3. Decode the secret (remove 'whsec_' prefix, decode hex)
  const secretKey = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const secretBytes = Buffer.from(secretKey, 'hex');

  // 4. Create signing string
  const signingString = `${messageId}.${timestamp}.${payload}`;

  // 5. Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signingString)
    .digest('base64');

  // 6. Timing-safe comparison
  const expectedBuffer = Buffer.from(expectedSignature);
  const providedBuffer = Buffer.from(signature);

  if (expectedBuffer.length !== providedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}

// Express.js middleware example
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString();
  const secret = process.env.WEBHOOK_SECRET;

  try {
    const isValid = verifyWebhookSignature(payload, req.headers, secret);
    if (!isValid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(payload);
    // Process the webhook event...

    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook verification failed:', error.message);
    res.status(400).json({ error: error.message });
  }
});

Python

python
import hmac
import hashlib
import base64
import time
from typing import Dict

def verify_webhook_signature(
    payload: str,
    headers: Dict[str, str],
    secret: str,
    tolerance_seconds: int = 300
) -> bool:
    """
    Verify a Hookbase webhook signature.

    Args:
        payload: The raw request body as a string
        headers: Dictionary containing webhook headers
        secret: Your signing secret (whsec_xxx format)
        tolerance_seconds: Maximum age of signature (default 5 minutes)

    Returns:
        True if signature is valid, False otherwise

    Raises:
        ValueError: If timestamp is too old or signature format is invalid
    """
    message_id = headers.get('x-hookbase-id', '')
    timestamp_str = headers.get('x-hookbase-timestamp', '')
    provided_signature = headers.get('x-hookbase-signature', '')

    # 1. Verify timestamp freshness
    try:
        timestamp = int(timestamp_str)
    except ValueError:
        raise ValueError('Invalid timestamp format')

    now = int(time.time())
    if abs(now - timestamp) > tolerance_seconds:
        raise ValueError('Webhook timestamp is too old or too far in the future')

    # 2. Extract signature (remove 'v1,' prefix)
    parts = provided_signature.split(',')
    if len(parts) != 2 or parts[0] != 'v1':
        raise ValueError('Invalid signature format')
    signature = parts[1]

    # 3. Decode the secret (remove 'whsec_' prefix, decode hex)
    secret_key = secret[6:] if secret.startswith('whsec_') else secret
    secret_bytes = bytes.fromhex(secret_key)

    # 4. Create signing string
    signing_string = f'{message_id}.{timestamp_str}.{payload}'

    # 5. Compute expected signature
    expected_signature = base64.b64encode(
        hmac.new(
            secret_bytes,
            signing_string.encode('utf-8'),
            hashlib.sha256
        ).digest()
    ).decode('utf-8')

    # 6. Timing-safe comparison
    return hmac.compare_digest(expected_signature, signature)


# Flask example
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks', methods=['POST'])
def handle_webhook():
    payload = request.get_data(as_text=True)
    secret = os.environ.get('WEBHOOK_SECRET')

    try:
        is_valid = verify_webhook_signature(
            payload,
            dict(request.headers),
            secret
        )

        if not is_valid:
            return jsonify({'error': 'Invalid signature'}), 401

        event = request.get_json()
        # Process the webhook event...

        return jsonify({'received': True}), 200

    except ValueError as e:
        return jsonify({'error': str(e)}), 400

Go

go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// VerifyWebhookSignature verifies a Hookbase webhook signature
func VerifyWebhookSignature(payload []byte, headers http.Header, secret string, toleranceSeconds int64) error {
	messageID := headers.Get("x-hookbase-id")
	timestampStr := headers.Get("x-hookbase-timestamp")
	providedSignature := headers.Get("x-hookbase-signature")

	// 1. Verify timestamp freshness
	timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
	if err != nil {
		return errors.New("invalid timestamp format")
	}

	now := time.Now().Unix()
	if math.Abs(float64(now-timestamp)) > float64(toleranceSeconds) {
		return errors.New("webhook timestamp is too old or too far in the future")
	}

	// 2. Extract signature (remove 'v1,' prefix)
	parts := strings.SplitN(providedSignature, ",", 2)
	if len(parts) != 2 || parts[0] != "v1" {
		return errors.New("invalid signature format")
	}
	signature := parts[1]

	// 3. Decode the secret (remove 'whsec_' prefix, decode hex)
	secretKey := secret
	if strings.HasPrefix(secret, "whsec_") {
		secretKey = secret[6:]
	}
	secretBytes, err := hex.DecodeString(secretKey)
	if err != nil {
		return fmt.Errorf("invalid secret format: %w", err)
	}

	// 4. Create signing string
	signingString := fmt.Sprintf("%s.%s.%s", messageID, timestampStr, string(payload))

	// 5. Compute expected signature
	mac := hmac.New(sha256.New, secretBytes)
	mac.Write([]byte(signingString))
	expectedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	// 6. Timing-safe comparison
	if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(signature)) != 1 {
		return errors.New("signature mismatch")
	}

	return nil
}

// HTTP handler example
func webhookHandler(w http.ResponseWriter, r *http.Request) {
	payload, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read body", http.StatusBadRequest)
		return
	}

	secret := os.Getenv("WEBHOOK_SECRET")

	if err := VerifyWebhookSignature(payload, r.Header, secret, 300); err != nil {
		http.Error(w, err.Error(), http.StatusUnauthorized)
		return
	}

	// Process the webhook event...

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"received": true}`))
}

Ruby

ruby
require 'openssl'
require 'base64'
require 'json'

module Hookbase
  class WebhookVerifier
    TOLERANCE_SECONDS = 300

    def initialize(secret)
      @secret = secret
    end

    def verify!(payload, headers, tolerance_seconds: TOLERANCE_SECONDS)
      message_id = headers['x-hookbase-id'] || headers['HTTP_X_HOOKBASE_ID']
      timestamp_str = headers['x-hookbase-timestamp'] || headers['HTTP_X_HOOKBASE_TIMESTAMP']
      provided_signature = headers['x-hookbase-signature'] || headers['HTTP_X_HOOKBASE_SIGNATURE']

      # 1. Verify timestamp freshness
      timestamp = Integer(timestamp_str)
      now = Time.now.to_i

      if (now - timestamp).abs > tolerance_seconds
        raise SignatureError, 'Webhook timestamp is too old or too far in the future'
      end

      # 2. Extract signature (remove 'v1,' prefix)
      parts = provided_signature.split(',', 2)
      unless parts.length == 2 && parts[0] == 'v1'
        raise SignatureError, 'Invalid signature format'
      end
      signature = parts[1]

      # 3. Decode the secret (remove 'whsec_' prefix, decode hex)
      secret_key = @secret.start_with?('whsec_') ? @secret[6..] : @secret
      secret_bytes = [secret_key].pack('H*')

      # 4. Create signing string
      signing_string = "#{message_id}.#{timestamp_str}.#{payload}"

      # 5. Compute expected signature
      expected_signature = Base64.strict_encode64(
        OpenSSL::HMAC.digest('sha256', secret_bytes, signing_string)
      )

      # 6. Timing-safe comparison
      unless secure_compare(expected_signature, signature)
        raise SignatureError, 'Signature mismatch'
      end

      true
    end

    private

    def secure_compare(a, b)
      return false unless a.bytesize == b.bytesize

      l = a.unpack('C*')
      res = 0
      b.each_byte { |byte| res |= byte ^ l.shift }
      res == 0
    end
  end

  class SignatureError < StandardError; end
end

# Rails controller example
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.raw_post
    secret = ENV['WEBHOOK_SECRET']
    verifier = Hookbase::WebhookVerifier.new(secret)

    begin
      verifier.verify!(payload, request.headers)

      event = JSON.parse(payload)
      # Process the webhook event...

      render json: { received: true }, status: :ok
    rescue Hookbase::SignatureError => e
      render json: { error: e.message }, status: :unauthorized
    end
  end
end

PHP

php
<?php

class HookbaseWebhookVerifier
{
    private string $secret;
    private int $toleranceSeconds;

    public function __construct(string $secret, int $toleranceSeconds = 300)
    {
        $this->secret = $secret;
        $this->toleranceSeconds = $toleranceSeconds;
    }

    /**
     * Verify a Hookbase webhook signature
     *
     * @param string $payload The raw request body
     * @param array $headers Associative array of headers
     * @return bool True if valid
     * @throws InvalidArgumentException If verification fails
     */
    public function verify(string $payload, array $headers): bool
    {
        $messageId = $headers['x-hookbase-id'] ?? $headers['HTTP_X_HOOKBASE_ID'] ?? '';
        $timestampStr = $headers['x-hookbase-timestamp'] ?? $headers['HTTP_X_HOOKBASE_TIMESTAMP'] ?? '';
        $providedSignature = $headers['x-hookbase-signature'] ?? $headers['HTTP_X_HOOKBASE_SIGNATURE'] ?? '';

        // 1. Verify timestamp freshness
        $timestamp = (int) $timestampStr;
        $now = time();

        if (abs($now - $timestamp) > $this->toleranceSeconds) {
            throw new InvalidArgumentException('Webhook timestamp is too old or too far in the future');
        }

        // 2. Extract signature (remove 'v1,' prefix)
        $parts = explode(',', $providedSignature, 2);
        if (count($parts) !== 2 || $parts[0] !== 'v1') {
            throw new InvalidArgumentException('Invalid signature format');
        }
        $signature = $parts[1];

        // 3. Decode the secret (remove 'whsec_' prefix, decode hex)
        $secretKey = $this->secret;
        if (str_starts_with($secretKey, 'whsec_')) {
            $secretKey = substr($secretKey, 6);
        }
        $secretBytes = hex2bin($secretKey);

        // 4. Create signing string
        $signingString = "{$messageId}.{$timestampStr}.{$payload}";

        // 5. Compute expected signature
        $expectedSignature = base64_encode(
            hash_hmac('sha256', $signingString, $secretBytes, true)
        );

        // 6. Timing-safe comparison
        if (!hash_equals($expectedSignature, $signature)) {
            throw new InvalidArgumentException('Signature mismatch');
        }

        return true;
    }
}

// Usage example
$payload = file_get_contents('php://input');
$headers = getallheaders();
$secret = getenv('WEBHOOK_SECRET');

$verifier = new HookbaseWebhookVerifier($secret);

try {
    $verifier->verify($payload, $headers);

    $event = json_decode($payload, true);
    // Process the webhook event...

    http_response_code(200);
    echo json_encode(['received' => true]);
} catch (InvalidArgumentException $e) {
    http_response_code(401);
    echo json_encode(['error' => $e->getMessage()]);
}

Security Best Practices

Use Timing-Safe Comparison

Always use constant-time comparison functions to prevent timing attacks:

LanguageFunction
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
Gocrypto/subtle.ConstantTimeCompare()
RubyCustom XOR comparison or Rack::Utils.secure_compare()
PHPhash_equals()

Verify Timestamp Freshness

Check that the webhook timestamp is within an acceptable window (default: 5 minutes) to prevent replay attacks. Reject webhooks with timestamps that are too old or too far in the future.

Store Secrets Securely

  • Never hardcode your signing secret in source code
  • Use environment variables or a secrets manager
  • Rotate secrets periodically using the secret rotation API

Read Raw Body

Ensure you read the raw request body before any JSON parsing or middleware transformation, as the signature is computed over the exact bytes sent.

Troubleshooting

"Signature mismatch" Error

  1. Check the raw body: Ensure you're using the exact request body without any modifications
  2. Verify the secret: Make sure you're using the correct signing secret for the endpoint
  3. Check header extraction: Ensure headers are being read correctly (case-insensitive)

"Timestamp too old" Error

  1. Check server clock: Ensure your server's clock is synchronized using NTP
  2. Increase tolerance: If experiencing network delays, consider increasing the tolerance window
  3. Check for replay: Old timestamps may indicate a replay attack

"Invalid signature format" Error

  1. Check header presence: Verify all three required headers are present
  2. Check version prefix: The signature should start with v1,

Secret Rotation

Hookbase supports zero-downtime secret rotation with a grace period. During rotation:

  1. Both the old and new secrets are valid
  2. New webhooks are signed with the new secret
  3. After the grace period expires, only the new secret is valid

See the Secret Rotation API for implementation details.

Released under the MIT License.