Clav
Webhooks

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...e91a
  • t is the send time, in seconds since 1970 (Unix).
  • v1 is 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.