English
English
Appearance
English
English
Appearance
For deeper coverage and full sample receivers see Webhooks → HMAC-SHA256 signing. This page is a quick summary so a new technical CRM team can grasp the signing and verification model.
Your CRM receives webhooks pushed by Zorio to your URL. Without signing:
HMAC-SHA256 signing solves all three.
When you register a webhook through the Admin Console, the system generates a secret (32 alphanumeric chars). You:
ZORIO_WEBHOOK_SECRET).Zorio sends a POST with these headers:
X-Zorio-Event: pbx.call.hangup
X-Zorio-Timestamp: 1782706011
X-Zorio-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Zorio-Signature: sha256=ab9d7f4...
Content-Type: application/json; charset=utf-8signature = "sha256=" + lowercase(hex(HMAC-SHA256(secret, raw_body)))secret: the secret you stored when creating the webhook.raw_body: the raw bytes of the request body (DO NOT parse JSON first).X-Zorio-Timestamp is more than 300 seconds off from now → reject with 400.X-Zorio-Signature — use your language's timing-safe API.X-Zorio-Delivery UUID — use a distributed NX lock with 24h TTL.const crypto = require('crypto');
function verifyZorioWebhook(req, secret) {
// 1. Timestamp window
const ts = parseInt(req.headers['x-zorio-timestamp'], 10);
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) {
throw new Error('Stale timestamp');
}
// 2. Recompute
const expected = 'sha256=' +
crypto.createHmac('sha256', secret).update(req.rawBody).digest('hex');
// 3. Timing-safe compare
const sig = req.headers['x-zorio-signature'] || '';
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
throw new Error('Invalid signature');
}
}See Sample receivers in 4 languages.
// WRONG — body was already mutated by the parser
app.use(express.json());
app.post('/webhook', (req, res) => {
const expected = createHmac(secret, JSON.stringify(req.body)).digest('hex');
// → never matches because JSON.stringify differs from the bytes Zorio sent
});
// RIGHT — use the raw bytes
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = createHmac(secret, req.body).digest('hex');
});== instead of timing-safe compare // WRONG — leaks signature byte-by-byte through timing attacks
if (sig === expected) { ... }
// RIGHT
if (crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { ... }Skipping the timestamp check enables replay attacks. An attacker who captures one valid event can replay it a thousand times, even years later, and the signature still verifies.
Some reverse proxies (nginx + a compression module) or Cloudflare can re-compress the body → the bytes change. Disable compression for the webhook route:
location /webhook {
gzip off;
proxy_pass http://app;
}Zorio only delivers to HTTPS URLs. If your TLS cert is:
Test: curl https://your-webhook-url/ from the public internet, with no cert errors.
For stronger security, dedupe by (timestamp + body hash) on top of Delivery UUID:
const fingerprint = ts + ':' + sha256(rawBody);
const isNew = await redis.set(`zorio:fp:${fingerprint}`, '1', 'NX', 'EX', 600);
if (!isNew) return res.status(200).send('Duplicate fingerprint');Why: even if an attacker captures a valid request within the 300s window, they can't replay it because the fingerprint has already been seen.