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"}
HeaderDescription
Webhook-IdUnique identifier for this webhook job
Webhook-TimestampUnix timestamp (seconds since epoch)
Webhook-SignatureHMAC-SHA256 signature (format: v1,<signature>)

Finding Your Secret

Each endpoint has a unique secret for signature verification. Find it in the dashboard:

  1. Navigate to Applications → Select App → Endpoints
  2. Click the endpoint to view details
  3. Click "Reveal Secret" button
  4. 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

  1. Get your endpoint secret from the dashboard
  2. Add verification code to your webhook handler
  3. Send test webhook using the dashboard or API
  4. 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: