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

  1. 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.
  2. Compute HMAC-SHA256(signingSecret, "<cl-timestamp>.<body>") and hex-encode it.
  3. Compare your value to cl-signature with 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))
}