Back to Developers & API

How do webhooks work?

Register an endpoint and pick events; ASHR sends a signed POST when they happen. Verify the X-Demystify-Signature HMAC with the secret shown once, and reject stale or unsigned requests.

~3 min read · For admin · Updated 9 Oct 2026

Quick answer

Register an endpoint in Admin → Settings → Developer & API, choose the events you want, and ASHR sends a signed POST when they happen. Verify the X-Demystify-Signature header with the secret (shown once) so you can trust the payload.

Developer & API settings, where webhook endpoints are registered.
Developer & API settings, where webhook endpoints are registered.

The payload

{ "event": "leave.created", "created": 1725448800, "data": { "id": "…", "employee_id": "…" } }

Headers: X-Demystify-Event: leave.created and X-Demystify-Signature: t=1725448800,v1=<hex>.

Joiner and leaver events

employee.updated fires when an employee's status or department changes. Its data is the employee id and changed_fields, the names of the fields that changed, never their values. employee.exited fires once when an exit is completed, with the employee id only. Neither payload carries a name or an email: fetch GET /api/v1/employees/{id} with your key when you need the record.

{ "event": "employee.updated", "created": 1725448800, "data": { "id": "…", "changed_fields": ["department"] } }

Agent profile events

AI agents are workers too. worker.profile_changed fires when an agent is created or changed, or switched on or off. Its data carries the worker id, its status, change (created, updated, activated or deactivated), source, the names of the changed fields and profile_path. It never carries a name, an email or a value. Read the profile at profile_path with a key that has workers:read and workers:write. worker.activated and worker.deactivated fire when an agent is switched on or off.

{ "event": "worker.profile_changed", "created": 1725448800, "data": { "worker_id": "…", "status": "inactive", "change": "deactivated", "changed_fields": ["status"], "profile_path": "/api/v1/workers/…/profile" } }

Verify the signature

The signed string is `${t}.${rawBody}` and the signature is HMAC-SHA256 of it with your endpoint secret. Recompute and compare in constant time; reject if it doesn't match or t is more than a few minutes old.

import crypto from 'node:crypto'

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  const ok = crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex'))
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  return ok && fresh
}

Return a 2xx fast and process asynchronously; treat delivery as at-least-once, so make your handler idempotent.

Frequently asked questions

Which events can I subscribe to?
Events for leave requests, employees, documents, recruitment, payroll runs, attendance, helpdesk, expenses, workers and entitlements. The Developer & API settings list every event you can pick. Pick specific events or subscribe to everything.
What do employee.updated and employee.exited contain?
Only ids. employee.updated carries the employee id and the names of the fields that changed (status or department), never their values. employee.exited carries the employee id once, when an exit is completed. Neither carries a name or an email; read the employee through the API if you need more.
What do the agent events contain?
Only ids, states and field names. worker.profile_changed fires when an AI agent's profile changes, with the worker id, its status, what kind of change it was and the names of the changed fields. worker.activated and worker.deactivated fire when an agent is switched on or off. Read the full profile from the path the event names.
How do I know a webhook really came from ASHR?
Every request carries X-Demystify-Signature — an HMAC-SHA256 of "<timestamp>.<rawBody>" using your endpoint secret. Recompute it and compare; reject if it doesn't match or the timestamp is too old.
What if my endpoint is down?
Delivery is best-effort and each attempt is recorded. Build your handler to be idempotent (the same event may arrive more than once) and return a 2xx quickly.