Skip to main content
Support & Technical Documentation

Verifying webhook signatures

Premium Features documentation and configuration reference.

Loading documentation...
Markdown

Check HMAC-SHA256 webhook signatures for Premium Features, Ring Groups, Voicemail, Hybrid SIP Trunks and CrazyFax.

When you set a secret, Crazytel signs each webhook so your system can prove it came from us and was not changed. The signature is an HMAC-SHA256 hash, written in hexadecimal.

Always calculate the signature over the raw request body exactly as received, before parsing or reformatting the JSON. Even a small change, such as re-ordering fields or removing spaces, will produce a different signature.

Which header each feature uses

FeatureHeaderFormatSigned content
Announcements, IVR, Queues, AI Bridge, Time ConditionsX-Crazytel-Signature-256sha256=<hex>Raw body
Ring GroupsX-CrazyRingGroup-Signature<hex>Raw body
VoicemailX-CrazyVoicemail-Signature<hex>Raw body
Hybrid SIP Trunks (JSON webhook)X-Hybrid-Signature plus X-Hybrid-Timestamp<hex>Timestamp, then ., then raw body
CrazyFaxX-Fax-Signature plus X-Fax-Timestampsha256=<hex>Timestamp converted to Unix seconds, then ., then raw body
SMS delivery status (SMS API)X-CrazyAPI-Signature plus X-CrazyAPI-Timestamp<hex>Timestamp, then ., then raw body

Feature guides: Announcements, IVR, Queues, AI Bridge, Time Conditions, Ring Groups, Voicemail, Hybrid SIP Trunks, CrazyFax, SMS delivery status.

For SMS delivery status webhooks the secret is issued per account through the SMS API (or the SMS Center in the portal) rather than typed into a feature's webhook settings. See SMS delivery status webhooks for the secret endpoints and a full verification example.

Example: PHP

<?php
$secret  = getenv('CRAZYTEL_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');

// Premium features using X-Crazytel-Signature-256
$received = $_SERVER['HTTP_X_CRAZYTEL_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

http_response_code(204);
// Process $rawBody in the background.

For Ring Groups or Voicemail, read HTTP_X_CRAZYRINGGROUP_SIGNATURE or HTTP_X_CRAZYVOICEMAIL_SIGNATURE instead, and compare against hash_hmac('sha256', $rawBody, $secret) without the sha256= prefix.

Example: Hybrid SIP Trunks (Node.js)

const crypto = require('crypto');

function isValidHybridWebhook(rawBody, headers, secret) {
  const timestamp = headers['x-hybrid-timestamp'];
  const signature = headers['x-hybrid-signature'] || '';

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || ageSeconds > 300) {
    return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Example: CrazyFax (Python)

import hashlib
import hmac
import time
from datetime import datetime


def is_valid_fax_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers.get("X-Fax-Timestamp", "")
    signature = headers.get("X-Fax-Signature", "")

    try:
        signed_at = int(datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp())
    except ValueError:
        return False

    if abs(time.time() - signed_at) > 300:
        return False

    expected = "sha256=" + hmac.new(
        secret.encode(),
        f"{signed_at}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Use a constant-time comparison (such as hash_equals in PHP, timingSafeEqual in Node.js or hmac.compare_digest in Python) rather than a plain == check.

Need Help?