Webhooks
Get told the moment something changes in Mihnaro: the events, what each message holds, how to check its signature in PHP, JavaScript or Python, retries and the delivery log.
A webhook is an address on your server that Mihnaro calls whenever something happens in your company: an employee is added, a leave is approved, a payroll is approved. You don't have to keep asking the API for changes.
Add a webhook
- Open Company → API keys and webhooks and the Webhooks tab (you need the Manage API keys and webhooks permission).
- Choose Add webhook and fill in:
- Name, and the URL that will receive the messages. It must be
httporhttpsand reachable from the internet: addresses on a private network orlocalhostare refused. - Format: JSON (the default) or XML.
- Events: the events to send (see below).
- Optionally Authenticate with API key: Mihnaro then also sends that key's outgoing authentication (Basic, OAuth 1, OAuth 2 or a username and password) with each message.
- Name, and the URL that will receive the messages. It must be
- Save. The signing secret (
whsec_…) is shown once: copy it into your receiving system. - Use Send a test to send a
testevent right away and see the answer in the delivery log.
You can also manage webhooks with the API (POST /api/v1/app/companies/{company}/webhooks, which returns data.secret once) or with the MCP tool manage_webhook, signed in with a person's token. API keys can't manage webhooks.
The live demo company never sends webhooks.
Events
| Group | Event | Sent when |
|---|---|---|
| Employee | employee. |
An employee is added (by any means: screens, API, MCP, import) |
| Employee | employee. |
An employee's record changes; changed lists the fields |
| Org structure | org., org., org. |
A branch is added, changed or deleted |
| Org structure | org., org., org. |
A department or other unit is added, changed or deleted |
| Salary | salary. |
An employee's salary changes |
| Salary | payroll. |
A payroll run is approved |
| Salary | payroll. |
A payroll run is approved: its journal entries, for accounting |
| Loan | loan., loan. |
A loan is given, or paid off |
| Financial transaction | financial_transaction., financial_transaction. |
An earning or deduction is added or removed |
| Termination | employee. |
An employee's service ends |
| Requests | leave., leave. |
A leave is decided |
| Requests | request., request. |
Another request is decided |
GET /api/v1/app/companies/{company}/webhook-events returns the same list with the formats and the retry schedule.
The message
Each delivery is a POST to your URL:
POST /hooks/mihnaro HTTP/1.1
Content-Type: application/json
User-Agent: Mihnaro-Webhooks/1.0
X-Mihnaro-Event: test
X-Mihnaro-Delivery: 0b6f2a52-5c1e-4c43-9a4e-1f0e2d8c7b11
X-Mihnaro-Signature: t=1793650158,v1=48a0460620a1d0e6ce9c52e941916366c0781382c3854e4979d8b485107864b7
{"id":"0b6f2a52-5c1e-4c43-9a4e-1f0e2d8c7b11","event":"test","company_id":16,"sent_at":"2026-11-02T22:40:00+03:00","data":{"message":"Mihnaro is connected to this webhook."}}
idis the same asX-Mihnaro-Delivery, and stays the same on every retry and resend: use it to skip messages you have already handled.sent_atis on the company's clock, with its offset.- With the XML format the same message comes as
<webhook><event/><sent_at/><data>…</data></webhook>, with list items as<item>.
What data holds for each event:
{"event": "employee.created", "data": {"id": 6211, "employee_number": "EMP-0001", "name": {"en": "Ali Hassan", "ar": "علي حسن"}, "branch_id": 76, "status": "active"}}
{"event": "employee.updated", "data": {"id": 6211, "employee_number": "EMP-0001", "name": {"en": "Ali Hassan", "ar": "علي حسن"}, "branch_id": 76, "status": "active", "changed": ["phone", "department_id"]}}
{"event": "employee.terminated", "data": {"id": 6211, "employee_number": "EMP-0001", "name": {"en": "Ali Hassan", "ar": "علي حسن"}, "branch_id": 76, "status": "terminated", "terminated_on": "2026-11-30"}}
{"event": "org.branch.created", "data": {"id": 77, "name": {"en": "Basra", "ar": "البصرة"}, "code": "BSR"}}
{"event": "org.unit.updated", "data": {"id": 2905, "type": "department", "name": {"en": "Human Resources", "ar": "الموارد البشرية"}, "parent_id": null}}
{"event": "salary.changed", "data": {"employee_id": 6211, "old_salary": 1500000, "new_salary": 1750000, "effective_date": "2026-11-01"}}
{"event": "loan.created", "data": {"id": 31, "employee_id": 6211, "amount": 3000000, "installments": 10}}
{"event": "loan.paid", "data": {"id": 31, "employee_id": 6211, "amount": 3000000, "paid_at": "2027-09-01T10:15:00+03:00"}}
{"event": "financial_transaction.created", "data": {"id": 120, "employee_id": 6211, "type": "earning", "amount": 250000, "month": "2026-11"}}
{"event": "leave.approved", "data": {"id": 88, "employee_id": 6212, "leave_type_id": 41, "start_date": "2026-11-09", "end_date": "2026-11-11", "days": 3}}
{"event": "request.rejected", "data": {"id": 52, "employee_id": 6212, "type": "overtime"}}
{"event": "payroll.approved", "data": {"id": 14, "month": "2026-10", "kind": "regular", "employees": 10, "net": 18450000, "total_net": 18450000}}
{"event": "payroll.journal", "data": {"id": 14, "month": "2026-10", "kind": "regular", "lines": [{"journal": "…", "account": "…", "description": "…", "group": "…", "debit": 18450000, "credit": 0, "employee": null}]}}
The field names are exact; the values here are examples. Leave and request events carry only the employee's ID: call GET /api/v1/app/companies/{company}/employees/{employee} for the rest.
Check the signature
Every message is signed with your webhook's secret, so you know it came from Mihnaro and wasn't changed. The header is X-Mihnaro-Signature: t=<unix time>,v1=<signature>, where the signature is the HMAC-SHA256, in hex, of the time, a dot and the raw body exactly as received.
- Read the raw body before any JSON parsing (parsing and re-encoding changes it).
- Work out
HMAC_SHA256(secret, t + "." + body)and compare it withv1in constant time. - Refuse messages whose
tis more than 5 minutes away from now, so an old message can't be replayed. - Answer any
2xxquickly (within 10 seconds), then do the work.
PHP
<?php
function mihnaro_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m)) {
return false;
}
[, $timestamp, $signature] = $m;
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
return hash_equals($expected, $signature) && abs(time() - (int) $timestamp) <= $tolerance;
}
$body = file_get_contents('php://input');
if (! mihnaro_verify($body, $_SERVER['HTTP_X_MIHNARO_SIGNATURE'] ?? '', getenv('MIHNARO_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$message = json_decode($body, true);
http_response_code(200);
JavaScript (Node, Express)
import crypto from 'node:crypto';
import express from 'express';
function verifyMihnaro(rawBody, header, secret, toleranceSeconds = 300) {
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? '');
if (!match) return false;
const [, timestamp, signature] = match;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= toleranceSeconds;
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
const app = express();
app.post('/hooks/mihnaro', express.raw({ type: '*/*' }), (req, res) => {
const body = req.body.toString('utf8');
if (!verifyMihnaro(body, req.get('X-Mihnaro-Signature'), process.env.MIHNARO_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const message = JSON.parse(body);
res.sendStatus(200);
});
app.listen(3000);
Python (Flask)
import hashlib, hmac, os, re, time
from flask import Flask, request, abort
def verify_mihnaro(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
match = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", header or "")
if not match:
return False
timestamp, signature = match.groups()
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature) and abs(time.time() - int(timestamp)) <= tolerance
app = Flask(__name__)
@app.post("/hooks/mihnaro")
def mihnaro_hook():
if not verify_mihnaro(request.get_data(), request.headers.get("X-Mihnaro-Signature", ""), os.environ["MIHNARO_WEBHOOK_SECRET"]):
abort(400)
message = request.get_json()
return "", 200
We checked the three verify functions against a real signature from Mihnaro: each accepts it, and each refuses the same body with one extra character.
Retries and the delivery log
- Mihnaro waits up to 10 seconds for your answer and doesn't follow redirects. Any
2xxcounts as delivered. - Anything else is tried again after 1, 5, 30, 120 and 720 minutes (about 14½ hours in all). After the last try the delivery stays Not delivered, and you can send it again by hand.
- Every try is kept in the Delivery log tab: when, the status your server answered and the start of its answer. Open a delivery to see the message and its tries, and choose Send again.
- With the API:
GET /api/v1/app/companies/{company}/webhook-deliveries(webhook_id,status),GET …/webhook-deliveries/{delivery}andPOST …/webhook-deliveries/{delivery}/resend. - New signing secret on a webhook makes a new secret (shown once); the old one stops working at once.
Last updated: