A webhook is a URL on your system that KalHR calls when something happens: an employee joins, leave is approved, a
payroll is paid. Each call is an HTTPS POST with a short JSON event, signed so you can prove it came from KalHR. It
arrives within seconds.
Webhooks and the API work together. The event says what happened and carries the record's id; your system then fetches the details it needs with its API key. Events deliberately carry no pay, bank details or case notes.
Set up an endpoint
Endpoints are added in KalHR by someone with the Manage Webhooks permission. If that isn't you, send these steps to your KalHR administrator.
- Open Settings → Webhooks and select Add endpoint.
- Enter the Endpoint URL. It must start with
https://and be reachable from the internet; KalHR won't send to a private or internal address. - Optionally add a Description, such as Payroll sync to our accounting system.
- Choose the Events:
- Every event sends everything, including events added to KalHR later.
- Selected events sends only those ticked. See the Event catalogue.
- Select Add endpoint, copy the signing secret (it starts
whsec_) into your system's secret store, and select I've stored it. It is shown only once. - Select Test on the endpoint. KalHR sends a
webhook.testevent, and the result appears in the delivery log within seconds, with your endpoint's answer.
An organisation can have up to 10 endpoints.
What your endpoint receives
POST /kalhr-webhooks HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: KalHR-Webhooks/1.0
KalHR-Event-Id: 0b8e7f1a-2c3d-4e5f-8a9b-0c1d2e3f4a5b
KalHR-Event-Type: leave.approved
KalHR-Delivery-Id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
KalHR-Signature: t=1760884200,v1=5f0c2d…
{
"id": "0b8e7f1a-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
"type": "leave.approved",
"apiVersion": "2026-10-15",
"occurredAt": "2026-10-19T14:30:00.1234567Z",
"organizationId": "8c1d0e2f-4a6b-4c8d-9e0f-1a2b3c4d5e6f",
"data": {
"leaveRequestId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"employeeId": "4b9d2c1e-7a3f-4e8b-9c21-5d6e7f8a9b0c",
"leaveTypeId": "c0ffee00-1234-4abc-9def-0123456789ab",
"leaveType": "Annual leave",
"startDate": "2026-11-02T00:00:00",
"endDate": "2026-11-06T00:00:00",
"status": "Approved"
}
}
Headers
| Header | Meaning |
|---|---|
KalHR-Event-Id | The event's id, the same as id in the body |
KalHR-Event-Type | The event's type, the same as type in the body |
KalHR-Delivery-Id | This delivery of the event to this endpoint. A redelivery has a new one. |
KalHR-Signature | The signature to check. See Verifying signatures. |
The event
| Field | Meaning |
|---|---|
id | The event's id. It stays the same on every retry and redelivery, so use it to skip repeats. |
type | What happened, such as leave.approved |
apiVersion | The version of this format, currently 2026-10-15 |
occurredAt | When it happened, in UTC |
organizationId | Your organisation |
data | The details, which depend on type. Each event's are in the Event catalogue. |
What your endpoint must do
- Check the signature before trusting anything in the request. Refuse it with
400if it doesn't match. - Answer
2xxwithin 10 seconds. Do the real work afterwards, for example by putting the event on a queue. A slow answer counts as a failure and the event is sent again. - Expect repeats. An event can arrive more than once. Remember the
idof each event you've handled, and skip it if it comes again. - Don't rely on order. Events can arrive out of order. Compare
occurredAtwhen order matters, or fetch the record with the API to get its current state. - Answer at the URL itself. KalHR doesn't follow redirects; a
3xxcounts as a failure.
A minimal receiver
Python (Flask). Run pip install flask, save it as receiver.py, and run flask --app receiver run --port 3000.
import hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["KALHR_WEBHOOK_SECRET"]
handled = set() # use your database in production
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
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/kalhr-webhooks")
def kalhr_webhooks():
raw = request.get_data() # the body exactly as sent
if not verify_kalhr(raw, request.headers.get("KalHR-Signature", ""), SECRET):
return "", 400
event = json.loads(raw)
if event["id"] not in handled:
handled.add(event["id"])
print(event["type"], event["data"]) # in production, put it on a queue and return
return "", 200
The same in Node.js is in the Quickstart.