Webhooks
Signature verification & receiver
Verify the raw request body and persist each event before acknowledging delivery.
The example uses PHP 8 and PDO SQLite with an absolute Unix-style inbox path. It verifies and stores events; your separate worker must implement the business action.
Requires PHP 8 with PDO SQLite. Set ILLYVOIP_WEBHOOK_SECRET and an absolute ILLYVOIP_WEBHOOK_INBOX file path outside your public web directory, writable only by your application. Use one shared durable inbox if you run multiple receiver instances.
How to verify
signed_payload = timestamp + "." + raw_request_body
expected = HMAC_SHA256(webhook_secret, signed_payload)
compare expected with v1 from Illyvoip-SignatureMinimal PHP handler
This PHP example verifies each delivery and stores it in a durable inbox. Process stored events separately; the example does not perform your business action.
<?php
// PHP 8+, PDO SQLite. Keep the inbox outside your public web directory.
// Set ILLYVOIP_WEBHOOK_SECRET and ILLYVOIP_WEBHOOK_INBOX (an absolute file path).
function verifyIllyvoipEvent(string $raw, string $header, string $secret, int $now): array
{
if ($secret === '' || !preg_match('/^t=(\d{1,12}),v1=([a-f0-9]{64})$/D', trim($header), $m)) {
throw new RuntimeException('Invalid signature header');
}
$timestamp = (int) $m[1];
// Recommended receiver tolerance. Signatures are refreshed on delivery retries.
if ($timestamp < $now - 300 || $timestamp > $now + 60) {
throw new RuntimeException('Signature timestamp outside tolerance');
}
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
if (!hash_equals($expected, $m[2])) {
throw new RuntimeException('Invalid signature');
}
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($event) || !is_string($event['id'] ?? null) || $event['id'] === '') {
throw new RuntimeException('Missing event ID');
}
return $event;
}
$secret = (string) getenv('ILLYVOIP_WEBHOOK_SECRET');
$inbox = (string) getenv('ILLYVOIP_WEBHOOK_INBOX');
if ($secret === '' || $inbox === '' || $inbox[0] !== '/') {
http_response_code(503); exit('Receiver is not configured');
}
try {
$raw = file_get_contents('php://input');
$event = verifyIllyvoipEvent($raw, $_SERVER['HTTP_ILLYVOIP_SIGNATURE'] ?? '', $secret, time());
} catch (Throwable $e) {
http_response_code(401); exit('Invalid webhook');
}
try {
$db = new PDO('sqlite:' . $inbox, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$db->exec('PRAGMA busy_timeout=3000');
$db->exec('CREATE TABLE IF NOT EXISTS webhook_inbox (event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, received_at INTEGER NOT NULL, processed_at INTEGER NULL)');
$save = $db->prepare('INSERT OR IGNORE INTO webhook_inbox (event_id, payload, received_at) VALUES (?, ?, ?)');
$save->execute([$event['id'], $raw, time()]);
// Acknowledge only after durable storage. A duplicate event is already stored.
// Process unprocessed rows separately and make your business effects idempotent.
http_response_code(200); echo 'OK';
} catch (Throwable $e) {
// A temporary storage failure must remain retryable; do not acknowledge it.
http_response_code(503); echo 'Receiver temporarily unavailable';
}