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', 200Node.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
- Always verify in production - Never skip signature verification in production
- Use timing-safe comparison - Prevent timing attacks with constant-time comparison
- Check timestamp freshness - Optionally reject requests older than 5 minutes to prevent replay attacks
- Store secret securely - Use environment variables, not hardcoded values
- 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 TrueSee Webhook Overview for header details.