Verifying signatures
Every webhook delivery includes a Circa-Signature header. Verify it before trusting the payload.
The header format
Circa-Signature: t=1747000800,v1=8e4dbf…t— unix-seconds timestamp at the moment Circa computed the signature.v1— hex-encoded HMAC-SHA256 of the string{t}.{rawBody}using your endpoint's signing secret as the key, whererawBodyis the bytes you received in the HTTP body (not a re-serialized version of the parsed JSON).
⚠
Compute the HMAC over the raw request body. Re-encoding the parsed JSON will change whitespace and key order and the signature will no longer match.
Verification algorithm
- Read the
Circa-Signatureheader. - Split on
,intot=…andv1=…components. - Reject if
|now - t| > 300 seconds(5-minute replay tolerance). - Compute
expected = hex(hmac_sha256(secret, "{t}.{rawBody}")). - Constant-time compare
expectedwithv1. Accept if equal, reject otherwise.
Snippets
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Capture the RAW body — don't let express.json() rewrite it.
app.use(express.raw({ type: 'application/json' }));
function verifyCircaSignature(
header: string,
rawBody: Buffer,
secret: string,
toleranceSeconds = 300,
) {
const parts = Object.fromEntries(
header.split(',').map((kv) => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || typeof v1 !== 'string') return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/circa', (req, res) => {
const header = req.header('Circa-Signature') ?? '';
if (!verifyCircaSignature(header, req.body, process.env.CIRCA_WHSEC!)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// … handle event …
res.status(200).send('ok');
});