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
| Feature | Header | Format | Signed content |
|---|---|---|---|
| Announcements, IVR, Queues, AI Bridge, Time Conditions | X-Crazytel-Signature-256 | sha256=<hex> | Raw body |
| Ring Groups | X-CrazyRingGroup-Signature | <hex> | Raw body |
| Voicemail | X-CrazyVoicemail-Signature | <hex> | Raw body |
| Hybrid SIP Trunks (JSON webhook) | X-Hybrid-Signature plus X-Hybrid-Timestamp | <hex> | Timestamp, then ., then raw body |
| CrazyFax | X-Fax-Signature plus X-Fax-Timestamp | sha256=<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.
