Signed webhooks
CDE signs what it sends with HMAC-SHA256. There are two formats and they are not interchangeable: publishing to your own site signs the timestamp together with the body, in the x-cde-timestamp and x-cde-signature headers; event webhooks sign the body alone, in the cde-signature header.
The difference exists for a reason: publishing must resist the replay of a captured request, so the timestamp is part of the signature and the receiver rejects anything outside the window. An event webhook is delivered with a record and can be replayed by you, on purpose — there, a timestamp in the signature would get in the way.
Publishing to your own site
- x-cde-timestamp: the send time, in seconds since 1970.
- x-cde-signature: sha256=<hex> of the HMAC over the string `${timestamp}.${body}`.
- The receiver recomputes the same string from the RAW body and the timestamp header.
- Reject anything outside the window you consider safe (the CDE template uses five minutes).
Event webhooks
- cde-event: the event name you registered in the panel.
- cde-signature: sha256=<hex> of the HMAC over the body, with no timestamp.
- cde-delivery-id: the delivery identifier, so you can discard duplicates.
- cde-endpoint and cde-version: which endpoint received it and the format version.
Verifying the signature
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, signature) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}PHP
function verify(string $secret, string $rawBody, ?string $signature): bool {
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return is_string($signature) && hash_equals($expected, $signature);
}Python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or '')- Always use the RAW request body. If your server already parsed the JSON and you serialise it again, the signature will not match.
- Compare with a constant-time function (timingSafeEqual, hash_equals, compare_digest), never plain equality.
- Store the delivery identifier and discard duplicates: a replay is a feature, not an error.
Didn't find what you need?
The documentation follows the product and grows with the questions that arrive. Write through the contact form and say what was missing.