Verifying signatures
Every webhook delivery is signed so you can confirm it genuinely came from
ClientLoop and was not tampered with in transit. Verify the signature before
trusting a payload, and respond 200/201 only after it checks out.
How deliveries are signed
Each delivery carries these headers (HTTP header names are case-insensitive):
| Header | Description |
|---|---|
cl-signature |
Lowercase hex-encoded HMAC-SHA256 of the signed message. |
cl-timestamp |
Unix time, in seconds, when the delivery was signed. |
cl-request-id |
The delivery attempt's id — the same value as the payload's requestId. Provided for log correlation; it is not part of the signature. |
The signed message is the timestamp, a literal ., and the exact raw
request body:
<cl-timestamp>.<raw request body>The HMAC key is your webhook's signing secret — the value beginning
whsec_ that is shown exactly once when the webhook is created (and again when
you rotate it with webhookRotateSecret). Store it securely; it is the only
thing that lets you distinguish a real delivery from a forgery.
How to verify
- Read the raw request body before parsing it as JSON. You must HMAC the exact bytes you received — parsing and re-serializing changes them (key order, whitespace, number formatting) and the signatures will no longer match.
- Compute
HMAC-SHA256(signingSecret, "<cl-timestamp>.<body>")and hex-encode it. - Compare your value to
cl-signaturewith a constant-time comparison (not==) so an attacker cannot learn the expected signature from response-timing differences.
cl-timestamp is part of the signed message, so it cannot be altered — but do
not reject a delivery just because its timestamp looks old. Deliveries are
retried with backoff for up to 7 days, so a legitimate delivery can arrive long
after the event. Handle repeats by making your endpoint idempotent —
deduplicate on the payload's eventId, which is identical across every retry of
a delivery — rather than by rejecting late deliveries.
To rotate a secret without dropping deliveries, accept either the old or the new signing secret during the rollover window.
Examples
Each example takes the raw body, the two header values, and your signing secret, and returns whether the delivery is authentic.
import { createHmac, timingSafeEqual } from 'node:crypto';
// `rawBody` must be the exact bytes/string received, not a re-serialized object.
export function verifyWebhook(rawBody, signature, timestamp, signingSecret) {
if (!signature || !timestamp) return false;
const expected = createHmac('sha256', signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
def verify_webhook(raw_body: bytes, signature: str, timestamp: str,
signing_secret: str) -> bool:
if not signature or not timestamp:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(
signing_secret.encode(), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)function verify_webhook(
string $rawBody,
string $signature,
string $timestamp,
string $signingSecret
): bool {
if ($signature === '' || $timestamp === '') {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $signingSecret);
return hash_equals($expected, $signature);
}import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class WebhookSignature {
public static boolean verify(
byte[] rawBody, String signature, String timestamp, String signingSecret)
throws Exception {
if (signature == null || timestamp == null) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
}
}using System;
using System.Security.Cryptography;
using System.Text;
public static class WebhookSignature
{
public static bool Verify(
byte[] rawBody, string signature, string timestamp, string signingSecret)
{
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp))
return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret));
var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
var message = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, message, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, message, prefix.Length, rawBody.Length);
var expected = Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature));
}
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(rawBody []byte, signature, timestamp, signingSecret string) bool {
if signature == "" || timestamp == "" {
return false
}
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}