Signature verification
Verify the signature on a webhook before you trust its body.
Every delivery is signed so you can verify it came from Capvant. The X-Capvant-Signature header carries an HMAC-SHA256 of the raw request body, keyed with your endpoint's secret:
X-Capvant-Signature: sha256=6c1f0b...Replay protection
Two more headers bind the send time to the body: X-Capvant-Timestamp (unix seconds) and X-Capvant-Signature-256: t=<timestamp>,v1=<hmac>, where v1 is an HMAC-SHA256 over the string <timestamp>.<raw body> with the same secret. Verify v1 and refuse deliveries whose timestamp is older than your tolerance (five minutes is the usual choice); a captured delivery then cannot be replayed against you. Retries of the same event carry a fresh timestamp and signature. A delivery sent again through the replay endpoint carries X-Capvant-Signature only for now, so keep the first check for replays. Capvant never follows a redirect from your endpoint: answer 2xx directly on the registered URL.
Verify (Node)
import { createHmac, timingSafeEqual } from 'crypto';
function verify(rawBody, header, secret) {
const expected = 'sha256=' + createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const given = Buffer.from(header ?? '');
const wanted = Buffer.from(expected);
return given.length === wanted.length && timingSafeEqual(given, wanted);
}Verify the timestamped signature (Node)
import { createHmac, timingSafeEqual } from 'crypto';
function verifyV1(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
const wanted = Buffer.from(expected, 'hex');
return given.length === wanted.length && timingSafeEqual(given, wanted);
}Verify (Python)
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(header, expected)Compute the HMAC over the raw bytes before any JSON parsing; re-serialised JSON will not match.
