Skip to main content

Support & Resources

Verifying webhook signatures

Premium Features

Loading...

Verifying webhook signatures

Verifying webhook signatures

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

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

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?