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
| Event | When |
|---|---|
employee.created | An employee was added |
employee.updated | A partner-visible field of an employee changed |
employee.left | An employee's employment ended |
payroll_run.finalized | A payroll run was closed and its journal posted |
payroll_run.paid | A payroll run was marked paid and its payment journal posted |
payroll_run.corrected | A closed payroll run's amounts changed |
payroll_run.deleted | A finalized payroll run was deleted and its journal reversed |
attendance.rejected | A stored clock event could not become attendance |
ping | Test 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": { … }
}id— unique event ID; deduplicate on it.livemode—falsefor sandbox companies.data— the object concerned, in its API shape. Re-read the embedded object rather than diffing.
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
- At least once, not necessarily in order. Deduplicate on
id. - Reply
2xxwithin 10 seconds. Any other status, or no answer, is retried. - We retry after 1 min, 5 min, 30 min, 2 h, 12 h, 24 h.
- After 7 days of failures the endpoint is disabled (
active: false,disabled_reason: "delivery_failures") and the company owner is emailed. Re-enable it withPATCHonce fixed. - An endpoint is also disabled when the API key that owns it is revoked (
disabled_reason: "key_revoked").
| Attempt | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|
| After the previous failure | 1 min | 5 min | 30 min | 2 h | 12 h | 24 h |
Test and debug on your own
POST /v1/webhooks/{id}/test— sends apingevent on demand.GET /v1/webhooks/{id}/deliveries— the last 100 attempts: what we sent, your status code, the first 1 KB of your response, and when we will retry. Deliveries older than 30 days are removed.POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver— sends the same event again (sameidand payload, fresh signature and timestamp).- When webhooks ship, the company will see the same log in Ahlan Hamad under Integrations → API keys.