KalHR logo

Webhooks

Verifying signatures

Prove that a webhook came from KalHR and wasn't changed or replayed, with ready-made checks in Node.js, Python, C# and PHP.

Your webhook URL is public, so anyone could send it a request. Every request from KalHR carries a signature made with your endpoint's signing secret, which only KalHR and you know. Check it before trusting anything in the request.

How the signature is made

Each request has a KalHR-Signature header:

KalHR-Signature: t=1760884200,v1=5f0c2d8e…
  • t is when KalHR signed the request, in Unix seconds.
  • v1 is an HMAC-SHA256, written as lowercase hex, of the text {t}.{raw body}, keyed with your endpoint's whole signing secret, whsec_ included.

Every attempt is signed afresh, so a retry has a new t and a new v1.

How to check it

  1. Read t and v1 from the header.
  2. Refuse the request if t is more than 5 minutes from your clock. This stops someone replaying a request they captured earlier.
  3. Compute the HMAC-SHA256 of t, a full stop, and the raw body exactly as received, with your signing secret.
  4. Compare your result with v1 in constant time, and refuse the request if they differ.

Watch out

Use the raw body, before any JSON parsing. Parsing and re-serialising changes spacing and key order, and the signature won't match. Most frameworks parse the body for you; the samples below show how to get it unparsed.

Node.js

With Express, read the body with express.raw on this route.

const crypto = require("crypto");

function verifyKalhr(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/kalhr-webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyKalhr(raw, req.get("KalHR-Signature") || "", process.env.KALHR_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  res.sendStatus(200);
  const event = JSON.parse(raw);
  // Handle the event after answering, e.g. put it on a queue.
});

Python

With Flask, request.get_data() is the raw body; with Django, request.body.

import hashlib, hmac, time

def verify_kalhr(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

# Flask:
# if not verify_kalhr(request.get_data(), request.headers.get("KalHR-Signature", ""), SECRET):
#     return "", 400

C# (.NET)

In ASP.NET Core, read the body yourself rather than binding it to a model: var raw = await new StreamReader(Request.Body).ReadToEndAsync();

using System.Security.Cryptography;
using System.Text;

static bool VerifyKalhr(string rawBody, string header, string secret, int toleranceSeconds = 300)
{
    var parts = header.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2)
        .ToDictionary(p => p[0].Trim(), p => p[1].Trim());
    if (!parts.TryGetValue("t", out var t) || !long.TryParse(t, out var seconds) || !parts.TryGetValue("v1", out var v1))
        return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > toleranceSeconds)
        return false;
    var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
    var expected = Convert.ToHexString(mac).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1));
}

PHP

file_get_contents('php://input') is the raw body.

function verify_kalhr(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        $parts[$key] = $value;
    }
    if (!isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

$raw = file_get_contents('php://input');
if (!verify_kalhr($raw, $_SERVER['HTTP_KALHR_SIGNATURE'] ?? '', getenv('KALHR_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

When signatures don't match

Check these, in this order:

  1. The body was changed before checking. It was parsed, re-encoded or trimmed. Use the raw bytes.
  2. The wrong secret. Each endpoint has its own, and rotating it replaces it at once. Use the whole value, whsec_ included.
  3. Your clock is wrong. If your server's clock is more than 5 minutes out, every request looks old. Keep it in sync with NTP.

Rotate the secret

Select the key icon on the endpoint in Settings → Webhooks. The old secret stops working at once, so update your receiver straight away. Deliveries that fail in between are retried.

Last updated October 19, 2026

Missing an endpoint or an event?

Tell us what you're building. The API grows from what integrators ask for.

See KalHR in action - 30 min demo