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:
| Header | Description |
|---|---|
x-hookbase-id | Unique message identifier (e.g., wh_msg_abc123) |
x-hookbase-timestamp | Unix timestamp (seconds) when the webhook was sent |
x-hookbase-signature | The signature in format v1,{base64_signature} |
How Signatures Are Computed
Hookbase computes signatures using the following algorithm:
Create the signing string: Concatenate the message ID, timestamp, and raw JSON payload with periods:
{message_id}.{timestamp}.{payload}Decode the secret: Remove the
whsec_prefix from your signing secret and decode from hex to bytesCompute HMAC-SHA256: Sign the signing string using the decoded secret
Base64 encode: Encode the resulting signature as base64
Format the header: Prefix with version identifier:
v1,{base64_signature}
Verification Steps
To verify a webhook signature:
- Extract the timestamp and signature from the headers
- Verify the timestamp is within your tolerance window (recommended: 5 minutes)
- Construct the signing string using the message ID, timestamp, and raw request body
- Compute your own signature using your signing secret
- Compare signatures using a timing-safe comparison function
Code Examples
Node.js
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
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)}), 400Go
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
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
endPHP
<?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:
| Language | Function |
|---|---|
| Node.js | crypto.timingSafeEqual() |
| Python | hmac.compare_digest() |
| Go | crypto/subtle.ConstantTimeCompare() |
| Ruby | Custom XOR comparison or Rack::Utils.secure_compare() |
| PHP | hash_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
- Check the raw body: Ensure you're using the exact request body without any modifications
- Verify the secret: Make sure you're using the correct signing secret for the endpoint
- Check header extraction: Ensure headers are being read correctly (case-insensitive)
"Timestamp too old" Error
- Check server clock: Ensure your server's clock is synchronized using NTP
- Increase tolerance: If experiencing network delays, consider increasing the tolerance window
- Check for replay: Old timestamps may indicate a replay attack
"Invalid signature format" Error
- Check header presence: Verify all three required headers are present
- Check version prefix: The signature should start with
v1,
Secret Rotation
Hookbase supports zero-downtime secret rotation with a grace period. During rotation:
- Both the old and new secrets are valid
- New webhooks are signed with the new secret
- After the grace period expires, only the new secret is valid
See the Secret Rotation API for implementation details.