Skip to content
Mihnaro
Help center

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

  1. Open Company → API keys and webhooks and the Webhooks tab (you need the Manage API keys and webhooks permission).
  2. Choose Add webhook and fill in:
    • Name, and the URL that will receive the messages. It must be http or https and reachable from the internet: addresses on a private network or localhost are 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.
  3. Save. The signing secret (whsec_…) is shown once: copy it into your receiving system.
  4. Use Send a test to send a test event 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.created An employee is added (by any means: screens, API, MCP, import)
Employee employee.updated An employee's record changes; changed lists the fields
Org structure org.branch.created, org.branch.updated, org.branch.deleted A branch is added, changed or deleted
Org structure org.unit.created, org.unit.updated, org.unit.deleted A department or other unit is added, changed or deleted
Salary salary.changed An employee's salary changes
Salary payroll.approved A payroll run is approved
Salary payroll.journal A payroll run is approved: its journal entries, for accounting
Loan loan.created, loan.paid A loan is given, or paid off
Financial transaction financial_transaction.created, financial_transaction.deleted An earning or deduction is added or removed
Termination employee.terminated An employee's service ends
Requests leave.approved, leave.rejected A leave is decided
Requests request.approved, request.rejected 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."}}
  • id is the same as X-Mihnaro-Delivery, and stays the same on every retry and resend: use it to skip messages you have already handled.
  • sent_at is 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.

  1. Read the raw body before any JSON parsing (parsing and re-encoding changes it).
  2. Work out HMAC_SHA256(secret, t + "." + body) and compare it with v1 in constant time.
  3. Refuse messages whose t is more than 5 minutes away from now, so an old message can't be replayed.
  4. Answer any 2xx quickly (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 2xx counts 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} and POST …/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:

Still need help?

Our support team answers in English and Arabic.

Email support

We use cookies to understand how the site is used. You can accept or decline.