VoiceDock Docs
Webhooks

Webhook Security

Verify HMAC-SHA256 webhook signatures to ensure requests come from HMS Sovereign.

When you configure a webhook_secret on your assistant, HMS Sovereign signs all webhook requests. You should verify these signatures to ensure requests come from HMS Sovereign.

Signature Format

HMS Sovereign uses HMAC-SHA256 to sign webhooks. The signature is included in the X-Webhook-Signature header.

How It's Calculated

message = timestamp + "." + raw_request_body
signature = "sha256=" + HMAC-SHA256(secret, message)

The X-Webhook-Signature header value is prefixed with sha256=. Strip this prefix before comparing with your computed HMAC.

Verification Examples

Python

import hmac
import hashlib

def verify_webhook_signature(payload: str, secret: str, timestamp: str, signature: str) -> bool:
    """
    Verify HMS Sovereign webhook signature.

    Args:
        payload: Raw request body as string
        secret: Your webhook_secret from assistant config
        timestamp: X-Webhook-Timestamp header value
        signature: X-Webhook-Signature header value (e.g. "sha256=abc123...")

    Returns:
        True if signature is valid
    """
    # Strip the "sha256=" prefix from the header value
    sig_hex = signature.removeprefix("sha256=")
    message = f"{timestamp}.{payload}"
    expected = hmac.new(
        secret.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(sig_hex, expected)


# Flask example
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your-secret-here"

@app.route('/webhooks/hms-sovereign', methods=['POST'])
def handle_webhook():
    payload = request.get_data(as_text=True)
    timestamp = request.headers.get('X-Webhook-Timestamp')
    signature = request.headers.get('X-Webhook-Signature')
    
    if not verify_webhook_signature(payload, WEBHOOK_SECRET, timestamp, signature):
        abort(401, 'Invalid signature')
    
    # Process webhook...
    data = request.json
    # ...
    
    return 'OK', 200

Node.js

const crypto = require('crypto');

function verifyWebhookSignature(payload, secret, timestamp, signature) {
  // Strip the "sha256=" prefix from the header value
  const sigHex = signature.replace(/^sha256=/, '');
  const message = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(sigHex),
    Buffer.from(expected)
  );
}

// Express example
const express = require('express');
const app = express();

const WEBHOOK_SECRET = 'your-secret-here';

app.post('/webhooks/hms-sovereign', 
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const payload = req.body.toString();
    const timestamp = req.headers['x-webhook-timestamp'];
    const signature = req.headers['x-webhook-signature'];
    
    if (!verifyWebhookSignature(payload, WEBHOOK_SECRET, timestamp, signature)) {
      return res.status(401).send('Invalid signature');
    }
    
    const data = JSON.parse(payload);
    // Process webhook...
    
    res.status(200).send('OK');
  }
);

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func verifyWebhookSignature(payload, secret, timestamp, signature string) bool {
    // Strip the "sha256=" prefix from the header value
    sigHex := strings.TrimPrefix(signature, "sha256=")
    message := timestamp + "." + payload
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(message))
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(sigHex), []byte(expected))
}

PHP

<?php

function verifyWebhookSignature(
    string $payload,
    string $secret,
    string $timestamp,
    string $signature
): bool {
    // Strip the "sha256=" prefix from the header value
    $sigHex = str_replace('sha256=', '', $signature);
    $message = $timestamp . '.' . $payload;
    $expected = hash_hmac('sha256', $message, $secret);
    return hash_equals($expected, $sigHex);
}

// Usage
$payload = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'];
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];

if (!verifyWebhookSignature($payload, WEBHOOK_SECRET, $timestamp, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

Best Practices

  1. Always verify in production - Never skip signature verification in production
  2. Use timing-safe comparison - Prevent timing attacks with constant-time comparison
  3. Check timestamp freshness - Optionally reject requests older than 5 minutes to prevent replay attacks
  4. Store secret securely - Use environment variables, not hardcoded values
  5. Log verification failures - Monitor for suspicious activity

Timestamp Validation

Optionally validate the timestamp to prevent replay attacks:

import time

def verify_webhook(payload, secret, timestamp, signature, max_age_seconds=300):
    # Verify signature
    if not verify_webhook_signature(payload, secret, timestamp, signature):
        return False
    
    # Check timestamp freshness
    current_time = int(time.time())
    request_time = int(timestamp)
    
    if abs(current_time - request_time) > max_age_seconds:
        return False
    
    return True

See Webhook Overview for header details.

On this page