KalHR logo

Webhooks

Receiving webhooks

Have KalHR call your system the moment something happens. Set up an endpoint, see exactly what it receives, and learn what it must do in return.

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.

  1. Open Settings → Webhooks and select Add endpoint.
  2. 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.
  3. Optionally add a Description, such as Payroll sync to our accounting system.
  4. Choose the Events:
    • Every event sends everything, including events added to KalHR later.
    • Selected events sends only those ticked. See the Event catalogue.
  5. 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.
  6. Select Test on the endpoint. KalHR sends a webhook.test event, 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

HeaderMeaning
KalHR-Event-IdThe event's id, the same as id in the body
KalHR-Event-TypeThe event's type, the same as type in the body
KalHR-Delivery-IdThis delivery of the event to this endpoint. A redelivery has a new one.
KalHR-SignatureThe signature to check. See Verifying signatures.

The event

FieldMeaning
idThe event's id. It stays the same on every retry and redelivery, so use it to skip repeats.
typeWhat happened, such as leave.approved
apiVersionThe version of this format, currently 2026-10-15
occurredAtWhen it happened, in UTC
organizationIdYour organisation
dataThe details, which depend on type. Each event's are in the Event catalogue.

What your endpoint must do

  1. Check the signature before trusting anything in the request. Refuse it with 400 if it doesn't match.
  2. Answer 2xx within 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.
  3. Expect repeats. An event can arrive more than once. Remember the id of each event you've handled, and skip it if it comes again.
  4. Don't rely on order. Events can arrive out of order. Compare occurredAt when order matters, or fetch the record with the API to get its current state.
  5. Answer at the URL itself. KalHR doesn't follow redirects; a 3xx counts 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.

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