Verifying webhook signatures
The exact headers DemoMate sends on every webhook delivery and a copy-pasteable Node recipe to verify the HMAC-SHA256 signature and reject replays.
Every webhook DemoMate delivers is signed with your endpoint's secret so you can confirm the request genuinely came from DemoMate and was not tampered with in transit. Verify the signature on every delivery before acting on it.
Headers on every delivery
| Header | Value |
|---|---|
x-demomate-signature | base64( HMAC-SHA256( secret, "{timestamp}.{rawBody}" ) ) |
x-demomate-timestamp | Unix time in seconds — the same timestamp that is bound into the signature |
x-demomate-signature-2 | Present only during a secret rotation: the same body signed with your previous secret, valid for a 24h grace window |
x-demomate-event | The event name (e.g. lead.captured) |
x-demomate-delivery | Unique delivery id |
The signature covers "{timestamp}.{rawBody}" — the timestamp, a literal ., then the raw request body bytes. Because the timestamp is signed, it cannot be altered without breaking the signature, which lets you reject stale or replayed deliveries by checking how old the timestamp is.
How to verify
- Read
x-demomate-timestampand reject the delivery if it is older than a window you choose (e.g. 5 minutes) — this stops replays. - Recompute
base64(HMAC-SHA256(secret, "{timestamp}.{rawBody}"))over the raw body, exactly as received. Do not parse and re-serialize the JSON first — re-serialization changes bytes and breaks the signature. - Constant-time compare your result against
x-demomate-signature. During a rotation, also accept a match againstx-demomate-signature-2.
Node example
const crypto = require('node:crypto');
// Your endpoint's signing secret (whsec_...), shown once when you create or
// rotate the endpoint. Store it server-side; never ship it to a browser.
const SIGNING_SECRET = process.env.DEMOMATE_WEBHOOK_SECRET;
// Reject anything older than this many seconds to defeat replays.
const MAX_AGE_SECONDS = 300;
/**
* @param {string} rawBody The raw request body, exactly as received (a string
* or Buffer). Read this BEFORE any JSON parsing.
* @param {Record<string,string>} headers Lower-cased request headers.
*/
function verifyDelivery(rawBody, headers) {
const timestamp = headers['x-demomate-timestamp'];
const signature = headers['x-demomate-signature'];
if (!timestamp || !signature) return false;
// 1. Replay guard: the signed timestamp must be recent.
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age < 0 || age > MAX_AGE_SECONDS) return false;
// 2. Recompute the MAC over "{timestamp}.{rawBody}".
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('base64');
// 3. Constant-time compare. Accept x-demomate-signature-2 too — it carries the
// previous secret during a 24h rotation grace window.
const candidates = [
signature,
headers['x-demomate-signature-2'],
].filter(Boolean);
return candidates.some((candidate) => {
const a = Buffer.from(expected);
const b = Buffer.from(candidate);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
crypto.timingSafeEqual throws on length-mismatched buffers, so the a.length === b.length guard both short-circuits obvious mismatches and keeps the comparison safe.
Rotating a secret
When you rotate an endpoint's secret, DemoMate keeps signing with the old secret under x-demomate-signature-2 for 24 hours. Update your stored secret any time inside that window: your verifier accepts either header, so no delivery is dropped during the switch. After 24h, only the new secret is used and x-demomate-signature-2 stops being sent.