Skip to content

Webhook HMAC-SHA256 signing

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.

Why HMAC

Your CRM receives webhooks pushed by Zorio to your URL. Without signing:

  • An attacker can impersonate Zorio → POST fake events to your URL → your CRM mishandles them.
  • Payloads can be modified in transit (man-in-the-middle).
  • An attacker can replay a captured valid event.

HMAC-SHA256 signing solves all three.

Mechanics

When you create the webhook

When you register a webhook through the Admin Console, the system generates a secret (32 alphanumeric chars). You:

  1. Copy the secret immediately (shown only once).
  2. Store it in your receiver's environment (ZORIO_WEBHOOK_SECRET).
  3. DO NOT hardcode it, DO NOT commit it to git.

On every event Zorio delivers

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-8

How Zorio computes the signature

signature = "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).

How a receiver verifies

Four steps

  1. Reject stale timestamps — if X-Zorio-Timestamp is more than 300 seconds off from now → reject with 400.
  2. Recompute the signature using your secret and the raw body.
  3. Constant-time compare against X-Zorio-Signature — use your language's timing-safe API.
  4. Dedupe by X-Zorio-Delivery UUID — use a distributed NX lock with 24h TTL.

Node.js sample (minimal)

js
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');
  }
}

PHP / Python / Go samples

See Sample receivers in 4 languages.

Common pitfalls

Parsing JSON before computing the HMAC

js
// 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');
});

Using == instead of timing-safe compare

js
// WRONG — leaks signature byte-by-byte through timing attacks
if (sig === expected) { ... }

// RIGHT
if (crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { ... }

Not checking the timestamp

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.

Reverse proxy modifying the body

Some reverse proxies (nginx + a compression module) or Cloudflare can re-compress the body → the bytes change. Disable compression for the webhook route:

nginx
location /webhook {
    gzip off;
    proxy_pass http://app;
}

Wrong TLS setup

Zorio only delivers to HTTPS URLs. If your TLS cert is:

  • Self-signed → Zorio rejects immediately.
  • Expired → Zorio rejects.
  • Hostname mismatch → Zorio rejects.

Test: curl https://your-webhook-url/ from the public internet, with no cert errors.

Optional second-layer replay protection

For stronger security, dedupe by (timestamp + body hash) on top of Delivery UUID:

js
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.

Cấp phép theo điều khoản sử dụng của Zorio.