Call Lens
Webhooks

Verifying signatures

Check the signature before you parse the body.

Every delivery carries these headers:

X-CallLens-Signature:        t=1754384040,v1=8f3c...
X-CallLens-Event:            call.completed
X-CallLens-Idempotency-Key:  3f9a1c22-...
X-CallLens-Delivery-Attempt: 1

The scheme

v1 is HMAC-SHA256(secret, "{t}.{raw body}"), hex-encoded.

The timestamp is inside the MAC. A timestamp header that is not covered by the signature is decoration — an attacker replaying a captured delivery just edits it to now and the signature still verifies. Because t is signed, rejecting old timestamps actually rejects replays.

Three rules:

  1. Verify against the raw request body, before any JSON parsing or re-serialization. The MAC covers the exact bytes sent, not a canonical form.
  2. Look v1 up by name. Do not split on , and take the second field. A future v2 will appear alongside v1 during a migration window, and a positional parser breaks the moment it does.
  3. Compare in constant time. == on a hex MAC is a timing oracle.

Reject anything older than 300 seconds. That is a replay bound, not a clock-skew allowance.

PHP

function verify(string $header, string $rawBody, string $secret, int $tolerance = 300): bool
{
    $fields = [];
    foreach (explode(',', $header) as $pair) {
        $parts = explode('=', trim($pair), 2);
        if (count($parts) === 2) {
            $fields[$parts[0]] = $parts[1];
        }
    }

    if (! isset($fields['t'], $fields['v1']) || ! ctype_digit($fields['t'])) {
        return false;
    }

    if (abs(time() - (int) $fields['t']) > $tolerance) {
        return false;
    }

    return hash_equals(
        hash_hmac('sha256', $fields['t'].'.'.$rawBody, $secret),
        $fields['v1'],
    );
}

Node

const crypto = require('crypto');

function verify(header, rawBody, secret, tolerance = 300) {
  const fields = Object.fromEntries(
    header.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i).trim(), p.slice(i + 1)];
    }),
  );

  if (!fields.t || !fields.v1 || !/^\d+$/.test(fields.t)) return false;
  if (Math.abs(Date.now() / 1000 - Number(fields.t)) > tolerance) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${fields.t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(fields.v1, 'utf8');

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express, express.json() discards the raw body. Capture it first: app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } })). Verifying against JSON.stringify(req.body) will fail, intermittently and confusingly.

Python

import hashlib
import hmac
import time


def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    fields = {}
    for pair in header.split(","):
        key, _, value = pair.strip().partition("=")
        if value:
            fields[key] = value

    if "t" not in fields or "v1" not in fields or not fields["t"].isdigit():
        return False

    if abs(time.time() - int(fields["t"])) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{fields['t']}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, fields["v1"])

Go

package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

func Verify(header string, rawBody []byte, secret string, tolerance int64) bool {
	fields := map[string]string{}
	for _, pair := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(strings.TrimSpace(pair), "="); ok {
			fields[k] = v
		}
	}

	ts, err := strconv.ParseInt(fields["t"], 10, 64)
	if err != nil || fields["v1"] == "" {
		return false
	}

	if diff := time.Now().Unix() - ts; diff > tolerance || diff < -tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(fields["t"] + "."))
	mac.Write(rawBody)

	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(fields["v1"]))
}

On this page