Signatures
Every delivery carries a Cademi-Signature header, so your endpoint can confirm that the body came from Cademí and was not changed:
Cademi-Signature: t=1759147200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt is the time of the delivery, in Unix seconds. Each v1 is an HMAC-SHA256 of <t>.<body> with a signing secret of the webhook, in hexadecimal. The secret is in the dashboard (see Signing secret).
Verify
- Read
tand everyv1from the header. - Compute
HMAC-SHA256(secret, t + "." + body)over the raw body, exactly as received, before any JSON parsing. - Accept the delivery if your result matches any
v1, using a constant-time comparison. - Reject deliveries whose
tis more than 5 minutes away from your clock.
import crypto from 'node:crypto';
function verify(header, body, secret) {
const parts = header.split(',').map((p) => p.split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
return fresh && signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}While a new secret is replacing the old one, the header carries one v1 per active secret, the newest first. Checking against any of them keeps your endpoint working during the change.
API webhooks are signed the same way; see API webhooks.