ahlan hamad

Webhooks

Ships in v1.2. The contract is published so you can build against it now; the webhook endpoints are not live yet. Follow the changelog.

Webhooks tell your system that something changed — an employee was added, a payroll run was closed — instead of you polling the API. Every delivery is signed with the endpoint's secret.

Register an endpoint

Needs the webhooks:manage scope. The URL must be https:// on a public host; private and loopback addresses are refused. The response includes the signing secret (whsec_…) once — store it. To rotate the secret, delete the endpoint and create it again.

curl -X POST https://actions.ahlanhamad.com/v1/webhooks \
  -H "Authorization: Bearer $AH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example/webhooks/ahlan-hamad",
    "events": ["employee.updated", "payroll_run.finalized"]
  }'

Events

EventWhen
employee.createdAn employee was added
employee.updatedA partner-visible field of an employee changed
employee.leftAn employee's employment ended
payroll_run.finalizedA payroll run was closed and its journal posted
payroll_run.paidA payroll run was marked paid and its payment journal posted
payroll_run.correctedA closed payroll run's amounts changed
payroll_run.deletedA finalized payroll run was deleted and its journal reversed
attendance.rejectedA stored clock event could not become attendance
pingTest event sent by the "send test event" action

What a delivery looks like

POST /webhooks/ahlan-hamad HTTP/1.1
Content-Type: application/json
X-AH-Signature: t=1759132931,v1=5f2b…

{
  "id": "evt_…",
  "type": "payroll_run.finalized",
  "created_at": "2026-09-29T08:02:11Z",
  "company_id": "k57f…",
  "livemode": false,
  "data": { … }
}

Verify the signature

Every delivery carries X-AH-Signature: t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Verify against the raw body bytes, before you parse the JSON, and reject the delivery if the signature does not match or t is more than 5 minutes from your clock. Compare in constant time.

Node.js

import crypto from "node:crypto";

// rawBody: the exact bytes we sent, as a string — read it BEFORE JSON.parse
// (in Express: app.post(path, express.raw({ type: "application/json" }), …)).
export function verifyAhSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  const want = Buffer.from(expected, "hex");
  return given.length === want.length && crypto.timingSafeEqual(given, want);
}

Python

import hashlib, hmac, time

def verify_ah_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

PHP

function verify_ah_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    $t = (int)($parts['t'] ?? 0);
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}

// $rawBody = file_get_contents('php://input');
// $header  = $_SERVER['HTTP_X_AH_SIGNATURE'] ?? '';

Delivery and retries

Attempt234567
After the previous failure1 min5 min30 min2 h12 h24 h

Test and debug on your own