Verify the signature
How to confirm a webhook request came from Clav and was not altered or replayed by a third party.
Why verify
Your webhook URL is not secret, so anyone can send a POST to it. Without
checking the signature, your server has no way to tell a Clav event from a
forged request. Reject every request whose signature does not match.
Header format
x-clav-signature: t=1758024251,v1=5f2b0c...e91atis the send time, in seconds since 1970 (Unix).v1is the HMAC-SHA256, in hexadecimal, computed with the webhook secret.
The signed text is the timestamp, a dot and the raw request body:
<t>.<raw body>The timestamp is part of the signature so nobody can reuse an old request by
swapping only t.
Step by step
Read the raw body
Take the body exactly as it arrived, before any parsing. If your framework parses the JSON and you serialize it again, whitespace and field order can change and the signature will not match.
Split out t and v1
Split the x-clav-signature header on commas and each part on the equals sign.
Reject old timestamps
Compare t with your server’s clock. We recommend rejecting requests more than
5 minutes apart, which blocks replays of captured requests.
Compute and compare
Compute the HMAC-SHA256 of t + "." + body with the secret and compare it with
v1 using a constant-time comparison.
Node.js example
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function isValidClavSignature(input: {
rawBody: string;
header: string | null;
secret: string;
}): boolean {
if (!input.header) return false;
const parts = new Map(
input.header.split(",").map((part) => {
const [key, ...rest] = part.split("=");
return [key?.trim(), rest.join("=")] as const;
}),
);
const timestamp = Number(parts.get("t"));
const received = parts.get("v1");
if (!Number.isInteger(timestamp) || !received) return false;
const age = Math.abs(Date.now() / 1000 - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", input.secret)
.update(timestamp + "." + input.rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Python example
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def is_valid_clav_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
timestamp = parts.get("t", "")
received = parts.get("v1")
if not timestamp.isdigit() or not received:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)Use the secret as text
The secret is shown as a base64url string. Use that string directly as the HMAC key, without decoding it.
Common problems
The signature never matches
Almost always the body was changed before verification. Make sure you are using
the raw body, not the output of JSON.stringify on the already parsed object.
Also check that the secret belongs to the right webhook, since each webhook has
its own.
Some requests are rejected for their timestamp
Your server’s clock is probably ahead or behind. Sync it with NTP. Each retry is signed again, with a fresh timestamp.