Verifying Webhook Signatures
Hook Mesh signs every webhook with HMAC-SHA256 to ensure authenticity. This guide shows you how to verify signatures in your webhook handlers.
Always verify signatures! Without verification, anyone could forge webhooks to your endpoints.
How Signing Works
Hook Mesh creates a signature for each webhook using these steps:
1
Construct signed content
{webhook_id}.{timestamp}.{json_payload}2
Compute HMAC-SHA256
HMAC-SHA256(secret, signed_content)3
Base64 encode result
base64(hmac_result)4
Send in header
Webhook-Signature: v1,{signature}Webhook Headers
Every webhook includes these headers:
POST /webhooks/hookmesh HTTP/1.1
Host: your-app.com
Content-Type: application/json
Webhook-Id: job_5nM8pQ1rK3vL9xB
Webhook-Timestamp: 1737380400
Webhook-Signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
{"user_id": "usr_123", "email": "alice@example.com"}| Header | Description |
|---|---|
Webhook-Id | Unique identifier for this webhook job |
Webhook-Timestamp | Unix timestamp (seconds since epoch) |
Webhook-Signature | HMAC-SHA256 signature (format: v1,<signature>) |
Finding Your Secret
Each endpoint has a unique secret for signature verification. Find it in the dashboard:
- Navigate to Applications → Select App → Endpoints
- Click the endpoint to view details
- Click "Reveal Secret" button
- Copy the secret (format:
whsec_...)
Keep secrets secure! Store them in environment variables or a secret management service. Never commit to version control.
Code Examples
Here's how to verify webhook signatures in different languages:
Node.js / JavaScript
webhook-verify.js
const crypto = require('crypto');
function verifyWebhook(payload, headers, secret) {
const webhookId = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
if (!webhookId || !timestamp || !signature) {
throw new Error('Missing required headers');
}
// Extract signature (format: "v1,<signature>")
const parts = signature.split(',');
if (parts[0] !== 'v1') {
throw new Error('Invalid signature version');
}
const expectedSignature = parts[1];
// Construct signed content
const signedContent = `${webhookId}.${timestamp}.${JSON.stringify(payload)}`;
// Compute HMAC
const hmac = crypto.createHmac('sha256', secret);
hmac.update(signedContent);
const computedSignature = hmac.digest('base64');
// Compare signatures (timing-safe)
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(computedSignature)
)) {
throw new Error('Invalid signature');
}
// Verify timestamp (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) { // 5 minutes
throw new Error('Timestamp too old');
}
return true;
}
// Express.js usage
const express = require('express');
const app = express();
app.post('/webhooks/hookmesh', express.json(), (req, res) => {
const secret = process.env.HOOKMESH_WEBHOOK_SECRET;
try {
verifyWebhook(req.body, req.headers, secret);
// Process webhook
handleEvent(req.body);
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook verification failed:', error.message);
res.status(400).json({ error: error.message });
}
});
function handleEvent(payload) {
// Your webhook processing logic here
console.log('Processing webhook:', payload);
}Python
webhook_verify.py
import hmac
import hashlib
import time
import base64
import json
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
def verify_webhook(payload, headers, secret):
webhook_id = headers.get('Webhook-Id')
timestamp = headers.get('Webhook-Timestamp')
signature = headers.get('Webhook-Signature')
if not all([webhook_id, timestamp, signature]):
raise ValueError('Missing required headers')
# Extract signature (format: "v1,<signature>")
parts = signature.split(',')
if parts[0] != 'v1':
raise ValueError('Invalid signature version')
expected_signature = parts[1]
# Construct signed content
signed_content = f"{webhook_id}.{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
# Compute HMAC
computed_signature = base64.b64encode(
hmac.new(
secret.encode('utf-8'),
signed_content.encode('utf-8'),
hashlib.sha256
).digest()
).decode('utf-8')
# Compare signatures (timing-safe)
if not hmac.compare_digest(expected_signature, computed_signature):
raise ValueError('Invalid signature')
# Verify timestamp (prevent replay attacks)
now = int(time.time())
if abs(now - int(timestamp)) > 300: # 5 minutes
raise ValueError('Timestamp too old')
return True
@app.route('/webhooks/hookmesh', methods=['POST'])
def handle_webhook():
secret = os.environ.get('HOOKMESH_WEBHOOK_SECRET')
try:
verify_webhook(request.json, request.headers, secret)
# Process webhook
handle_event(request.json)
return jsonify({'received': True}), 200
except ValueError as e:
print(f'Webhook verification failed: {str(e)}')
return jsonify({'error': str(e)}), 400
def handle_event(payload):
# Your webhook processing logic here
print(f'Processing webhook: {payload}')
if __name__ == '__main__':
app.run(port=3000)Go
webhook_verify.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func verifyWebhook(payload []byte, headers http.Header, secret string) error {
webhookID := headers.Get("Webhook-Id")
timestamp := headers.Get("Webhook-Timestamp")
signature := headers.Get("Webhook-Signature")
if webhookID == "" || timestamp == "" || signature == "" {
return errors.New("missing required headers")
}
// Extract signature (format: "v1,<signature>")
parts := strings.Split(signature, ",")
if len(parts) != 2 || parts[0] != "v1" {
return errors.New("invalid signature version")
}
expectedSignature := parts[1]
// Construct signed content
signedContent := fmt.Sprintf("%s.%s.%s", webhookID, timestamp, string(payload))
// Compute HMAC
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(signedContent))
computedSignature := base64.StdEncoding.EncodeToString(h.Sum(nil))
// Compare signatures (timing-safe)
if !hmac.Equal([]byte(expectedSignature), []byte(computedSignature)) {
return errors.New("invalid signature")
}
// Verify timestamp (prevent replay attacks)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return errors.New("invalid timestamp")
}
now := time.Now().Unix()
if math.Abs(float64(now-ts)) > 300 { // 5 minutes
return errors.New("timestamp too old")
}
return nil
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
secret := os.Getenv("HOOKMESH_WEBHOOK_SECRET")
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Verify signature
if err := verifyWebhook(body, r.Header, secret); err != nil {
log.Printf("Webhook verification failed: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process webhook
var payload map[string]interface{}
json.Unmarshal(body, &payload)
handleEvent(payload)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func handleEvent(payload map[string]interface{}) {
// Your webhook processing logic here
log.Printf("Processing webhook: %+v", payload)
}
func main() {
http.HandleFunc("/webhooks/hookmesh", webhookHandler)
log.Println("Server starting on :3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}PHP / Laravel
WebhookController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class WebhookController extends Controller
{
public function handleHookMesh(Request $request)
{
$secret = config('services.hookmesh.webhook_secret');
try {
$this->verifyWebhook(
$request->getContent(),
$request->headers->all(),
$secret
);
// Process webhook
$this->handleEvent($request->json()->all());
return response()->json(['received' => true]);
} catch (\Exception $e) {
Log::error('Webhook verification failed: ' . $e->getMessage());
return response()->json(['error' => $e->getMessage()], 400);
}
}
private function verifyWebhook($payload, $headers, $secret)
{
$webhookId = $headers['webhook-id'][0] ?? null;
$timestamp = $headers['webhook-timestamp'][0] ?? null;
$signature = $headers['webhook-signature'][0] ?? null;
if (!$webhookId || !$timestamp || !$signature) {
throw new \Exception('Missing required headers');
}
// Extract signature (format: "v1,<signature>")
$parts = explode(',', $signature);
if (count($parts) !== 2 || $parts[0] !== 'v1') {
throw new \Exception('Invalid signature version');
}
$expectedSignature = $parts[1];
// Construct signed content
$signedContent = "$webhookId.$timestamp.$payload";
// Compute HMAC
$computedSignature = base64_encode(
hash_hmac('sha256', $signedContent, $secret, true)
);
// Compare signatures (timing-safe)
if (!hash_equals($expectedSignature, $computedSignature)) {
throw new \Exception('Invalid signature');
}
// Verify timestamp (prevent replay attacks)
$now = time();
if (abs($now - (int)$timestamp) > 300) { // 5 minutes
throw new \Exception('Timestamp too old');
}
return true;
}
private function handleEvent($payload)
{
// Your webhook processing logic here
Log::info('Processing webhook', $payload);
}
}Common Errors
❌ "Invalid signature"
The computed signature doesn't match the expected signature.
Common causes:
- Wrong secret (check dashboard for current secret)
- JSON serialization mismatch (use compact JSON, no whitespace)
- Incorrect signed content format
❌ "Timestamp too old"
The webhook timestamp is more than 5 minutes old.
Common causes:
- Server clock is out of sync (sync with NTP)
- Timezone issues (use UTC timestamps)
- Replay attack (reject old webhooks)
❌ "Missing required headers"
One or more webhook headers are missing.
Common causes:
- Case-sensitive header names (use lowercase)
- Reverse proxy stripping headers
- Not a real Hook Mesh webhook
Testing Your Implementation
- Get your endpoint secret from the dashboard
- Add verification code to your webhook handler
- Send test webhook using the dashboard or API
- Check logs for verification success/failure
Tip: Use the "Send Test Webhook" button in the dashboard to quickly test your verification code.
Best Practices
- ✅ Always verify signatures - Never process unverified webhooks
- ✅ Check timestamp - Reject webhooks older than 5 minutes
- ✅ Use timing-safe comparison - Prevents timing attacks
- ✅ Store secrets securely - Environment variables or secret managers
- ✅ Log verification failures - Monitor for potential attacks
- ✅ Handle errors gracefully - Return appropriate HTTP status codes
Next Steps
Now that you can verify webhook signatures:
- Secret Rotation - Rotate secrets without downtime
- Idempotency - Handle duplicate webhooks
- Troubleshooting - Common issues and solutions