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
completedmay arrive before you finished processingbroadcasting. - Respond
2xxfast to acknowledge; do heavy work asynchronously. A non-2xx or a timeout (>15s) is retried up to 18 times over ~3 days.