English
English
Appearance
English
English
Appearance
Every webhook Zorio sends to your receiver is signed with HMAC-SHA256 using the secret you saved at registration. Purpose:
| Header | Description |
|---|---|
X-Zorio-Event | Event name, e.g. pbx.call.hangup |
X-Zorio-Timestamp | UTC epoch seconds when Zorio published the event |
X-Zorio-Delivery | Unique UUID for each delivery (retries included) — use for dedupe |
X-Zorio-Signature | sha256=<hex> HMAC-SHA256 of the raw body with the secret |
Content-Type | application/json; charset=utf-8 |
signature = "sha256=" + lowercase(hex(HMAC-SHA256(secret, raw_body)))secret: 32 alphanumeric chars generated when the webhook was created.raw_body: the raw bytes of the request body — DO NOT parse JSON before computing.Compare X-Zorio-Timestamp with current time. If it's off by more than 300 seconds (5 minutes) → reject with 400.
Why: defeat replay attacks (an attacker captures one valid request and resends it many times).
expected = "sha256=" + lowercase(hex(HMAC-SHA256(secret, raw_body)))Compare expected against X-Zorio-Signature with a constant-time function (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hash_equals in PHP).
DO NOT use == for string comparison
A plain == comparison can leak the signature byte by byte through timing attacks. Always use your language's timing-safe API.
X-Zorio-Delivery Store the processed X-Zorio-Delivery UUID in Redis/DB with 24h TTL. If you've already seen the UUID → return 200 immediately (skip reprocessing).
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.ZORIO_WEBHOOK_SECRET;
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = parseInt(req.headers['x-zorio-timestamp'], 10);
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(400).send('Stale timestamp');
}
const sig = req.headers['x-zorio-signature'] || '';
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
console.log('Verified event:', event.event, event.data);
res.status(200).send('OK');
});
app.listen(3000);More samples in Python, PHP, Go at Sample receivers.
Frequent causes:
express.raw(). In server frameworks use $request->getContent(). In Flask use request.get_data().} with no trailing newline.ntpd / chronyd to sync via NTP.