Anyone who learns your webhook URL can send it a request. Checking the signature proves a request came from Clarion and hasn’t been changed. Checking its timestamp stops someone replaying a request they captured. Do both before acting on any webhook.
You need the subscription’s signing secret (whsec_…), shown once when you set up the webhook.
The header
X-Clarion-Signature-V2: t=1759823052,v1=9c4e0f...e71a
tis the Unix time, in seconds, when this attempt was sent. It’s also inX-Clarion-Timestamp.v1is the hex HMAC-SHA256, keyed with your signing secret, of the string`${t}.${rawBody}`— the timestamp, a full stop, and the request body exactly as received.
Verify it
- Read the raw request body before any JSON parsing. Re-serialising parsed JSON changes whitespace and breaks the comparison.
- Split the header on commas and read
tandv1. - Reject the request if
tis more than 300 seconds from your current time. - Compute the HMAC of
`${t}.${rawBody}`with your secret. - Compare it to
v1in constant time. - Check
X-Clarion-Deliveryagainst the ids you’ve already handled, and skip duplicates.
Node.js
const crypto = require("crypto");
function verify(rawBody, headers, secret, toleranceSeconds = 300) {
const header = headers["x-clarion-signature-v2"] || "";
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
// Reject anything outside the window, so a captured request stops working.
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
// Constant-time — a plain === leaks the signature a byte at a time.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
With Express, capture the raw body by mounting express.raw({ type: "application/json" }) on the webhook route and passing req.body.toString("utf8") as rawBody.
Python
import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
header = headers.get("X-Clarion-Signature-V2", "")
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
sig = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
Deduplicate retries
Clarion retries failed deliveries up to five times, and you can retry by hand from Recent deliveries. Every retry carries the same X-Clarion-Delivery id (and the same id in the body). Store the ids you’ve processed and ignore repeats — that’s what makes handling safe to run twice.
A retry is signed with a new timestamp, so a legitimate retry hours later still passes the 300-second check. createdAt in the body stays the time the event happened.
The deprecated header
Requests also carry X-Clarion-Signature: sha256=<hex>, an HMAC over the body alone. It has no timestamp, so a captured request signed this way stays valid forever. It’s still sent so older integrations keep working. Verify X-Clarion-Signature-V2 and stop reading the old header.
If verification fails
- Check you’re hashing the raw bytes, not re-serialised JSON.
- Check the secret belongs to this subscription — each webhook has its own.
- Check your server clock. A clock more than five minutes off rejects every request.