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…
tis when KalHR signed the request, in Unix seconds.v1is 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
- Read
tandv1from the header. - Refuse the request if
tis more than 5 minutes from your clock. This stops someone replaying a request they captured earlier. - Compute the HMAC-SHA256 of
t, a full stop, and the raw body exactly as received, with your signing secret. - Compare your result with
v1in 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:
- The body was changed before checking. It was parsed, re-encoded or trimmed. Use the raw bytes.
- The wrong secret. Each endpoint has its own, and rotating it replaces it at once. Use the whole value,
whsec_included. - 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.