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.

Where the Secret Comes From

A webhook secret can be set at three levels: on the assistant, on the phone number, and on the organisation. The assistant wins over the number, and the number wins over the organisation.

The URL and the secret always come from the same level. Whichever level supplies the URL for an event also supplies the secret used to sign it. If that level has a URL but no secret, the request is sent unsigned — the X-Webhook-Signature header is left out entirely, and no secret from a lower level is borrowed to sign it.

This matters when you verify. If you set a URL on an assistant, set the secret there too; an organisation-level secret will not be applied to it.

An unsigned request carries no X-Webhook-Signature header at all. Treat a missing header as a failed verification, not as a request to accept — otherwise anyone who learns your endpoint can post to it.

Choosing a Secret

You can generate a secret in the dashboard or supply your own through the API. Any string of 16 to 256 visible characters without whitespace is accepted; it is used verbatim as the HMAC key, so nothing about its shape changes how signatures are computed.

Generated secrets look like whsec_ followed by 64 hexadecimal characters. You are free to reuse one secret across several assistants, numbers or organisations — there is no uniqueness requirement — and a secret you generate at one level can be pasted into another.

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