Skip to content
Chefotech HRMS
All documentation

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 app

Polling 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

http
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.

javascript
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
});
Node and Express
python
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
Python and Flask
php
<?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);
PHP

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.

Related