Developers and API
Webhooks: getting events pushed to you
Be told the moment something happens instead of polling — with signature verification you can copy.
Open this in the appPolling versus being told
Without webhooks an integration has to ask 'has anything changed?' on a timer — wasteful when nothing has, and slow when something has. A webhook reverses that: the moment a leave request is approved or a payroll run is published, we POST the event to a URL you own.
Every notification the platform can raise is available as a webhook. Subscribing to * means events added in future reach you without any change on your side.
Setting one up
- 1. Build an endpoint that accepts POST with a JSON body and answers 200 quickly. Do the slow work after replying — we time out after ten seconds.
- 2. In Settings → API & webhooks → Webhooks, add the URL and tick the events you want.
- 3. Copy the signing secret shown once at creation.
- 4. Press Test to send a synthetic event and confirm your endpoint answers.
What a delivery looks like
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: ChefoTech-HRMS-Webhooks/1.0
X-Chefotech-Event: leave.approved
X-Chefotech-Delivery: 6a9da0c18e4e6d5911a5d2f4
X-Chefotech-Timestamp: 1788712345
X-Chefotech-Signature: v1=6f3c…9ab2
{
"event": "leave.approved",
"organizationId": "6a9d9f3d8e4e6d5911a5cce3",
"sentAt": "2026-09-06T17:52:25.412Z",
"data": { "employee": { "name": "Priya Nair" }, "leave": { "days": 2 } },
"entity": { "type": "LeaveRequest", "id": "6a9da0…" }
}Verify the signature before trusting anything
Your webhook URL is reachable by anyone who learns it. The signature is what proves a delivery came from us: HMAC-SHA256 over the timestamp, a dot, and the raw request body, keyed with the webhook secret, hex-encoded and prefixed with v1=.
Hash the raw bytes. Parsing the JSON and re-serialising it changes the bytes, and the signature will never match — this is the single most common integration mistake.
const crypto = require("node:crypto");
const express = require("express");
const app = express();
const SECRET = process.env.CHEFOTECH_WEBHOOK_SECRET;
// express.raw keeps the body as bytes; express.json would discard them.
app.post("/webhooks/chefotech", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Chefotech-Timestamp");
const signature = req.get("X-Chefotech-Signature") || "";
const raw = req.body.toString("utf8");
const expected = "v1=" + crypto
.createHmac("sha256", SECRET)
.update(timestamp + "." + raw)
.digest("hex");
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
res.sendStatus(200); // answer first
handle(JSON.parse(raw)); // work afterwards
});import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CHEFOTECH_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/chefotech")
def chefotech():
timestamp = request.headers.get("X-Chefotech-Timestamp", "")
signature = request.headers.get("X-Chefotech-Signature", "")
raw = request.get_data() # bytes, exactly as sent
signed = timestamp.encode() + b"." + raw
expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
# queue the work, reply immediately
return "", 200<?php
$secret = getenv("CHEFOTECH_WEBHOOK_SECRET");
$timestamp = $_SERVER["HTTP_X_CHEFOTECH_TIMESTAMP"] ?? "";
$signature = $_SERVER["HTTP_X_CHEFOTECH_SIGNATURE"] ?? "";
$raw = file_get_contents("php://input");
$expected = "v1=" . hash_hmac("sha256", $timestamp . "." . $raw, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit("bad signature");
}
http_response_code(200);
$event = json_decode($raw, true);Retries, duplicates and pausing
A delivery that does not answer 2xx within ten seconds is retried up to five times with growing gaps. Every attempt is recorded — open Deliveries on the webhook to see the status, response code and body of each one.
Because of retries you can receive the same event twice. Treat X-Chefotech-Delivery as an idempotency key: record the ids you have processed and ignore repeats.
After twenty consecutive failures the webhook is switched off, so a dead endpoint stops generating work forever. Fix your endpoint, then resume it from the list.
Rotating the secret invalidates the old one immediately, so update your receiver in the same maintenance window.
Testing before you have a server
Your laptop has no public URL, so we cannot reach it. Either run a tunnel — ngrok and Cloudflare Tunnel both give you a public address that forwards to localhost — or point the webhook at a throwaway inbox such as webhook.site to see the exact headers and body we send before writing any code.