Skip to content

HMAC-SHA256 signing

Every webhook Zorio sends to your receiver is signed with HMAC-SHA256 using the secret you saved at registration. Purpose:

  • Authenticate that the event came from Zorio (not an impersonator).
  • Verify integrity of the payload (not altered in transit).
  • Block replay attacks via a timestamp window.

Headers Zorio sends

HeaderDescription
X-Zorio-EventEvent name, e.g. pbx.call.hangup
X-Zorio-TimestampUTC epoch seconds when Zorio published the event
X-Zorio-DeliveryUnique UUID for each delivery (retries included) — use for dedupe
X-Zorio-Signaturesha256=<hex> HMAC-SHA256 of the raw body with the secret
Content-Typeapplication/json; charset=utf-8

How Zorio computes the signature

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.

How a receiver verifies

Step 1 — Reject stale timestamps

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

Step 2 — Recompute the signature

expected = "sha256=" + lowercase(hex(HMAC-SHA256(secret, raw_body)))

Step 3 — Constant-time compare

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.

Step 4 — Dedupe by 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).

Node.js (Express) sample

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

Common errors

Signatures don't match even with the right secret

Frequent causes:

  • Parsing JSON before computing the HMAC — you must use the RAW body, not a parsed object. In Express use express.raw(). In server frameworks use $request->getContent(). In Flask use request.get_data().
  • Charset encoding — must be raw UTF-8 bytes, no re-encoding.
  • Body modified by middleware — e.g. nginx/CDN re-gzipping then unzipping → bytes change. Configure your stack to skip body modification on the webhook route.
  • Trimming whitespace — DO NOT trim; the last byte may be } with no trailing newline.

Timestamp is always off

  • Your server clock is drifting — run ntpd / chronyd to sync via NTP.
  • The receiver runs in a container without clock sync — make sure the container uses the right timezone.

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