Skip to main content

Verify webhooks

Every delivery carries a zenvault-signature header:

zenvault-signature: t=<unix-seconds>,v1=<hex-hmac>

The signature base is "<t>.<raw-request-body>", HMAC-SHA256 with your endpoint secret (the oneTimeSecret from endpoint creation), hex-encoded.

Verify against the raw body (before JSON parsing), use a constant-time compare, and reject deliveries whose t is outside a tolerance (±5 min) to blunt replay.

Node.js

const crypto = require('crypto');

function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) {
return false; // stale or malformed
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

:::warning Use the raw body Frameworks that auto-parse JSON will re-serialize the body differently and break the HMAC. Capture the raw bytes (e.g. Express express.raw()) for the webhook route specifically. :::

Make handlers idempotent

Deliveries are at-least-once and unordered:

  • Dedupe on zenvault-event-id (stable across retries).
  • Treat the transfer state machine as the source of truth rather than assuming events arrive in order — e.g. a completed may arrive before you finished processing broadcasting.
  • Respond 2xx fast to acknowledge; do heavy work asynchronously. A non-2xx or a timeout (>15s) is retried up to 18 times over ~3 days.