Events and webhooks
Every state change that other modules or external systems care about is published as a domain event through the transactional outbox. Inside the product, events drive projections, notifications and payroll inputs. Outside, a subset is delivered to customers as signed webhooks. The outbox, relay and job mechanics are in System design. The API rules are in the API overview.
1. Event naming and envelope
- Names are
<module>.<entity>.<past-tense-verb>, for exampleatt.day.computed,lve.request.approved,pay.run.locked. The names below match the module specs. - Payloads are thin: IDs, dates, statuses and the facts consumers need. They never contain C3 or C4 data (salary amounts, bank, Aadhaar, PAN, GPS points, photos). A consumer that needs more calls the API with its own permissions. Classification rules are in the data model overview.
- Each event type has a zod schema and a
schemaVersion. Additive fields don't change the version; a breaking change publishes the new version alongside the old one for the 6-month deprecation window.
Internal envelope (stored in sys_outbox_event, CloudEvents 1.0 attribute names): id, type, source, subject, time, tenantid, dataschema, actor, traceparent, data. See System design.
Webhook body (what receivers get):
{
"id": "0193b3b2-4c1d-7e2f-8a3b-4c5d6e7f8091",
"type": "lve.request.approved",
"schemaVersion": 1,
"tenantId": "01928f00-0000-7000-8000-00000000a001",
"occurredAt": "2026-11-04T05:45:40Z",
"subject": { "type": "lve.request", "id": "0193b3b0-2222-7333-8444-555566667777" },
"actor": { "type": "USER", "id": "01928f10-...", "onBehalf": false },
"data": {
"requestId": "0193b3b0-2222-7333-8444-555566667777",
"employeeId": "01929a44-1111-7222-8333-444455556666",
"leaveTypeCode": "CL",
"fromDate": "2026-11-13",
"toDate": "2026-11-16",
"chargedDays": "4.00",
"aggregateVersion": 4
}
}
Event IDs are UUIDv7 (time-ordered). aggregateVersion lets receivers discard stale events, because delivery order is not guaranteed.
2. Event catalogue
Webhook column: Yes = can be subscribed to by tenants (public API, Professional plan and above); No = internal only. Producers are the owning module; consumers are internal subscribers.
2.1 Organisation, identity and core HR
| Event | Payload summary | Producer | Consumers | Webhook |
|---|---|---|---|---|
org.location.created | locationId, code, name, state, timezone | ORG | ATT (fences), PAY (PT/LWF state), RPT | Yes |
org.location.updated | locationId, changed field names | ORG | ATT, PAY, RPT | Yes |
org.geofence.changed | locationId, geofenceId, change | ORG | ATT, MOB (cached fences) | No |
org.holiday.changed | calendarId, dates[], change (ADDED, REMOVED, UPDATED) | ORG | ATT (recompute), LVE (re-quote future requests), NTF | Yes |
org.department.deactivated | departmentId | ORG | EMP | Yes |
org.designation.deactivated | designationId | ORG | EMP | Yes |
org.legal_entity.updated | legalEntityId, changed field names | ORG | PAY (statutory registrations) | No |
org.account.created | accountId, name, accountType, ownerEmployeeId | ORG | FFM, TSM | Yes |
org.account.updated | accountId, changed field names | ORG | FFM, TSM | Yes |
iam.user.invited | userId, employeeId, channel | IAM | NTF (send invite) | No |
iam.user.activated | userId, employeeId | IAM | EMP (onboarding task), NTF | Yes |
iam.user.locked | userId, reason | IAM | NTF (user and admin) | No |
iam.user.deactivated | userId, employeeId, reason | IAM | MOB (wipe tokens), NTF | Yes |
iam.role.changed | roleId, changeType | IAM | Permission cache, audit | No |
iam.role_assignment.changed | userId, roleId, scope | IAM | Permission cache, audit | No |
iam.device.approval_requested | deviceId, userId, platform | IAM | NTF (manager task) | No |
iam.device.approved | deviceId, userId | IAM | MOB | No |
iam.device.revoked | deviceId, userId | IAM | MOB | No |
iam.session.revoked | sessionId, userId, reason | IAM | MOB, web | No |
emp.employee.hired | employeeId, employeeCode, joiningDate, locationId, designationId | EMP | NTF (pre-boarding link), RPT | Yes |
emp.preboarding.submitted | employeeId | EMP | NTF (HR task) | No |
emp.employee.joined | employeeId, employeeCode, joiningDate, locationId, departmentId, designationId, managerEmployeeId, employmentType | EMP | IAM (create user, invite), ATT, LVE (pro-rata accrual), PAY, LMS, ENG | Yes |
emp.job.changed | employeeId, validFrom, changeReason, changed dimensions (location, department, designation, grade, manager) | EMP | ATT, LVE, PAY, IAM (team scope), RPT | Yes |
emp.employee.confirmed | employeeId, confirmationDate | EMP | LVE (eligibility), NTF | Yes |
emp.document.submitted | documentId, employeeId, docType | EMP | NTF | No |
emp.document.approved | documentId, employeeId, docType | EMP | NTF | No |
emp.document.rejected | documentId, employeeId, docType | EMP | NTF | No |
emp.change_request.approved | changeRequestId, employeeId, section | EMP | PAY (bank change for the next run), NTF | No |
emp.task.overdue | taskId, employeeId, context | EMP | NTF (reminder, escalation) | No |
emp.onboarding.completed | employeeId | EMP | NTF, RPT | Yes |
emp.import.committed | batchId, importType, committedCount | EMP | RPT, IAM (bulk invites) | No |
emp.employee.separated | employeeId, lastWorkingDate, separationType | EMP | All modules (closing) | Yes |
2.2 Attendance, leave and mobile
| Event | Payload summary | Producer | Consumers | Webhook |
|---|---|---|---|---|
att.punch.recorded | punchId, employeeId, punchedAt, direction, source, geofenceResult | ATT | ATT day computer, FFM (track session) | Yes (high volume; opt-in) |
att.punch.rejected | employeeId, source, reasonCode | ATT | NTF (notify employee), RPT | No |
att.day.computed | employeeId, workDate, status, firstHalf, secondHalf, paidFraction, workedMinutes, lateMinutes, otMinutes | ATT | LVE (comp-off eligibility), PAY (input invalidation), RPT | No (use att.period.locked, then the API) |
att.day.needs_action | employeeId, workDate, reason (MISSED_PUNCH, UNEXPLAINED_ABSENCE) | ATT | NTF (employee alert) | No |
att.request.submitted | requestId, employeeId, type, subType, fromDate, toDate | ATT | NTF (start workflow) | No |
att.request.approved | as submitted + onBehalf | ATT | ATT recompute, NTF | Yes |
att.request.rejected | requestId, employeeId, type | ATT | NTF | No |
att.request.cancelled | requestId, employeeId, type, fromDate, toDate | ATT | ATT recompute | Yes |
att.overtime.approved | employeeId, workDate, approvedMinutes, compensationMode | ATT | PAY (OT input), LVE (comp-off mode) | No |
att.late.threshold_breached | employeeId, periodStart, periodEnd, lateCount, deductDays | ATT | LVE (LATE_DEDUCTION), NTF | No |
att.period.locked | periodLockId, periodStart, periodEnd, payPeriodId | ATT | PAY (snapshot inputs), NTF | Yes |
att.period.unlocked | periodLockId, reason | ATT | PAY (invalidate the draft run) | Yes |
att.adjustment.created | adjustmentId, employeeId, workDate, paidFractionDelta, otMinutesDelta | ATT | PAY (next-run input) | No |
lve.request.submitted | requestId, employeeId, leaveTypeCode, fromDate, toDate, chargedDays | LVE | NTF (start workflow) | No |
lve.request.approved | as submitted + onBehalf | LVE | ATT (leave marks, recompute), NTF, calendar sync (R3) | Yes |
lve.request.rejected | requestId, employeeId, leaveTypeCode | LVE | NTF | Yes |
lve.request.withdrawn | requestId, employeeId | LVE | NTF | No |
lve.request.cancellation_requested | requestId, employeeId | LVE | NTF (cancellation workflow) | No |
lve.request.cancelled | requestId, employeeId, fromDate, toDate | LVE | ATT (recompute), PAY (adjustment if locked) | Yes |
lve.balance.accrued | leaveTypeCode, periodKey, employeeCount (batched per tenant) | LVE | RPT | No |
lve.ledger.adjusted | entryId, employeeId, leaveTypeCode, days, reason | LVE | NTF (inform employee), RPT | No |
lve.comp_off.credited | creditId, employeeId, workedDate, days, expiresOn | LVE | NTF | No |
lve.comp_off.expiring | creditId, employeeId, expiresOn | LVE | NTF (reminder) | No |
lve.comp_off.expired | creditId, employeeId, days | LVE | NTF | No |
lve.cycle.closed | cycleId, cycleEnd, counts of carry-forward, lapse and encashment | LVE | PAY (encashment inputs), RPT | Yes |
lve.encashment.approved | encashmentId, employeeId, leaveTypeCode, days | LVE | PAY (one-time input) | No |
mob.sync.batch_received | batchId, installId, kind, itemCount | MOB | ATT (punch ingest), FFM (points, visits) | No |
mob.kiosk.enrolled | kioskId, locationId | MOB | IAM (device token), audit | No |
mob.face.enrolled | employeeId, consentId | MOB | Audit, consent register | No |
mob.face.deleted | employeeId, reason | MOB | Audit, consent register | No |
mob.app.version_blocked | platform, appVersion | MOB | Ops metrics | No |
2.3 Payroll, claims and exit
| Event | Payload summary | Producer | Consumers | Webhook |
|---|---|---|---|---|
pay.compensation.changed | compensationId, employeeId, validFrom, reason (no amounts) | PAY | PAY arrears calculator, NTF (revision letter), RPT | No |
pay.input.created | inputId, employeeId, periodId, inputType, sourceModule | PAY | PAY run invalidation | No |
pay.run.created | runId, periodId, runType | PAY | RPT | No |
pay.run.calculated | runId, calcVersion, employeeCount, errorCount, warningCount | PAY | NTF (preparer), RPT | No |
pay.run.submitted | runId | PAY | NTF (approver task) | No |
pay.run.approved | runId, approvedBy | PAY | NTF | Yes |
pay.run.locked | runId, periodId, runType, employeeCount, lockedAt | PAY | ATT (close period), CLM, EXT, NTF, accounting export | Yes |
pay.payslip.published | runId, employeeIds[] (batched), publishedAt | PAY | NTF (email, push) | Yes |
pay.statutory_file.generated | fileId, runId, scheme, state | PAY | NTF, RPT | Yes |
pay.bank_file.generated | bankFileId, runId, bankFormat, rowCount | PAY | NTF | Yes |
pay.run.paid | runId, paidAt | PAY | CLM, EXT, NTF | Yes |
pay.run.reversed | runId, reversalRunId | PAY | ATT, CLM, NTF | Yes |
pay.declaration.submitted | declarationId, employeeId, financialYear | PAY | NTF (payroll task) | No |
pay.loan.approved | loanId, employeeId, loanType | PAY | NTF | No |
clm.claim.submitted | claimId, claimNo, employeeId, lineCount | CLM | NTF (workflow) | No |
clm.claim.verified | claimId, verifiedBy | CLM | NTF | No |
clm.claim.approved | claimId, claimNo, employeeId, approvedPaise, payoutMode | CLM | PAY (input when paid through payroll), NTF | Yes |
clm.claim.rejected | claimId, reason | CLM | NTF | Yes |
clm.claim.sent_back | claimId, reason | CLM | NTF | No |
clm.claim.paid | claimId, paidAt, payoutBatchId | CLM | NTF, RPT | Yes |
clm.advance.approved | advanceId, employeeId, amountPaise | CLM | PAY or payout batch | No |
clm.duplicate.suspected | claimId, lineId, matchedLineId | CLM | NTF (finance) | No |
ext.separation.submitted | separationId, employeeId, requestedLwd | EXT | NTF (workflow) | No |
ext.separation.approved | separationId, employeeId, approvedLwd, separationType | EXT | EMP, LVE, ATT, FFM, NTF | Yes |
ext.separation.rejected | separationId, employeeId | EXT | EMP, NTF | No |
ext.separation.withdrawn | separationId, employeeId | EXT | EMP, NTF | Yes |
ext.termination.recorded | separationId, employeeId, effectiveDate, immediateRevoke | EXT | EMP, IAM, NTF | Yes |
ext.clearance.completed | separationId | EXT | EXT (F&F readiness) | No |
ext.lwd.reached | employeeId, lastWorkingDate | EXT | IAM (deactivate), EMP, ATT, MOB | Yes |
ext.fnf.initiated | settlementId, separationId, employeeId | EXT | PAY (FNF run), LVE (encashment), CLM (settle) | No |
ext.fnf.approved | settlementId, employeeId | EXT | NTF | No |
ext.fnf.paid | settlementId, employeeId, paidAt | EXT | NTF (statement, letters) | Yes |
2.4 Field force, timesheets, learning, engagement and reporting
| Event | Payload summary | Producer | Consumers | Webhook |
|---|---|---|---|---|
ffm.session.started | sessionId, employeeId, mode | FFM | MOB (tracking on), RPT | No |
ffm.session.ended | sessionId, employeeId | FFM | MOB (tracking off), RPT | No |
ffm.visit.checked_in | visitId, employeeId, accountId, checkedInAt, locationResult | FFM | ATT (optional auto-punch), NTF | Yes |
ffm.visit.completed | visitId, durationMinutes | FFM | RPT | Yes |
ffm.opportunity.stage_changed | opportunityId, accountId, fromStageId, toStageId | FFM | RPT, NTF | Yes |
ffm.opportunity.won | opportunityId, accountId, finalValuePaise | FFM | NTF, RPT | Yes |
ffm.payment.collected | paymentId, opportunityId, amountPaise, method | FFM | NTF (finance), RPT | Yes |
ffm.travel.day_closed | dailyTravelId, employeeId, workDate, computedM | FFM | NTF (employee review) | No |
ffm.travel.verified | dailyTravelId, employeeId, workDate, revisedM | FFM | CLM (mileage claim) | No |
tsm.timesheet.submitted | timesheetId, employeeId, weekStart, totalMinutes | TSM | NTF (workflow) | No |
tsm.timesheet.approved | timesheetId, employeeId, weekStart | TSM | NTF, RPT | Yes |
tsm.timesheet.rejected | timesheetId, employeeId | TSM | NTF, RPT | No |
tsm.defaulter.detected | employeeId, weekStart | TSM | NTF | No |
tsm.period.locked | periodStart, periodEnd | TSM | RPT, billing export | Yes |
lms.enrollment.assigned | enrollmentId, courseId, employeeId, dueDate | LMS | NTF | No |
lms.enrollment.overdue | enrollmentId, employeeId | LMS | NTF (employee, then manager) | No |
lms.course.completed | enrollmentId, courseId, employeeId, completedAt, scorePercent | LMS | EMP (checklist task), ENG | Yes |
lms.certificate.issued | certificateNo, enrollmentId, employeeId | LMS | NTF | Yes |
eng.announcement.published | announcementId, title, audienceCount | ENG | NTF | Yes |
eng.announcement.acknowledged | announcementId, employeeId | ENG | RPT | No |
eng.recognition.sent | recognitionId, fromEmployeeId, toEmployeeId, typeId | ENG | NTF (recipient and manager) | No |
eng.celebration.generated | employeeId, kind, date | ENG | NTF | No |
rpt.export.ready | exportId, datasetKey, rowCount, expiresAt | RPT | NTF (in-app link) | Yes |
rpt.export.failed | exportId, errorCode | RPT | NTF | No |
rpt.schedule.delivered | scheduleId, recipientCount | RPT | Audit | No |
2.5 Workflow, notifications and platform
| Event | Payload summary | Producer | Consumers | Webhook |
|---|---|---|---|---|
ntf.instance.started | instanceId, requestType, subjectId, requesterEmployeeId | NTF | RPT | No |
ntf.task.created | taskId, instanceId, assigneeUserId, dueAt | NTF | NTF fan-out (approver notification), MOB badge | No |
ntf.task.reminded | taskId, assigneeUserId | NTF | NTF fan-out, RPT (SLA) | No |
ntf.task.escalated | taskId, fromUserId, toUserId, level | NTF | NTF fan-out, RPT (SLA) | No |
ntf.instance.completed | instanceId, requestType, subjectId, outcome | NTF | Owning module (in-process decision hook), RPT | No |
ntf.message.failed | messageId, channel, provider, errorCode | NTF | Ops alerting | No |
ntf.webhook.failed | subscriptionId, eventId, attempts, lastStatusCode | NTF | Tenant admin notification, DLQ | No |
ntf.webhook.tested | subscriptionId | NTF | The receiver only (test delivery) | Yes (test only) |
att.policy.published, lve.policy.published, pay.policy.published, clm.policy.published | policyId, versionId, validFrom | Owning module | Policy cache, att.range.recompute, RPT | No |
plt.tenant.provisioned | tenantId, cellId, planCode | Control plane | App cell seeding confirmation, NTF (welcome) | No |
plt.tenant.activated | tenantId | Control plane | Billing, NTF | No |
plt.tenant.suspended | tenantId, reason | Control plane | App plane (read-only mode), NTF | No |
plt.tenant.reactivated | tenantId | Control plane | App plane | No |
plt.tenant.churned | tenantId, exportDeadline | Control plane | Export job, NTF | No |
plt.tenant.deleted | tenantId | Control plane | Cell purge job, deletion certificate | No |
plt.entitlements.changed | tenantId, featureKeys[] | Control plane | Entitlement SDK cache, MOB navigation refresh | No |
plt.flag.changed | flagKey, targets | Control plane | Flag SDK cache | No |
plt.statutory_pack.published | packVersion, validFrom, schemes[] | Control plane | PAY (new rate versions), NTF (payroll managers) | No |
plt.support_access.granted | grantId, tenantId, accessMode, expiresAt | Control plane | IAM (temporary session), tenant audit log | No |
plt.cell.version_changed | cellId, version | Control plane | Release notes, monitoring | No |
Control-plane events travel on the control plane's own outbox and reach cells through the cached control-plane API and its invalidation messages; they are never delivered as tenant webhooks.
3. Webhook delivery
3.1 Request format
POST /hooks/spyrl HTTP/1.1
Host: hr-sync.client.example
Content-Type: application/json
User-Agent: Spyrl-Webhooks/1.0
Spyrl-Event-Id: 0193b3b2-4c1d-7e2f-8a3b-4c5d6e7f8091
Spyrl-Event-Type: lve.request.approved
Spyrl-Delivery-Id: 0193b3b2-9f00-7a00-8b00-c0d0e0f0a0b0
Spyrl-Delivery-Attempt: 1
Spyrl-Signature: t=1762235140,v1=6b2f0c3d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c
The body is the webhook envelope from §1.
3.2 Signing and verification
- Header:
Spyrl-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>. - The signed string is
t + "." + raw request body(the exact bytes received, before any JSON parsing). - The key is the subscription's secret (shown once at creation, stored envelope-encrypted).
- During secret rotation, both secrets sign for 24 hours and the header carries two
v1=values. A receiver accepts the request if anyv1matches. - Receivers must reject a timestamp more than 5 minutes away from their clock (replay protection) and should dedupe on
Spyrl-Event-Id.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySpyrlSignature(rawBody: Buffer, header: string, secret: string, toleranceSec = 300): boolean {
const parts = header.split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => Buffer.from(v, 'hex'));
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}
3.3 Retries, DLQ, replay and auto-disable
| Rule | Detail |
|---|---|
| Success | Any 2xx within 10 seconds. Redirects are not followed and count as failures. |
| Retries | 12 attempts within the 24-hour window (about 23 h): immediately, then after 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 3 h, 4 h, 4 h, 4 h and 4 h, with ±10% jitter (retry class R-HOOK in System design). 410 Gone stops retries for that subscription and pauses it. |
| Dead-letter queue | After the last attempt the delivery becomes DEAD_LETTERED, ntf.webhook.failed is raised, and tenant admins are notified. Dead-lettered deliveries stay replayable. |
| Replay | From the delivery log for 30 days: a single delivery, all dead-lettered deliveries, or every event of the subscribed types in a time range. Replays keep the original Spyrl-Event-Id and get a new Spyrl-Delivery-Id. |
| Auto-disable | After 3 days of continuous failure the subscription moves to DISABLED_FAILING, and the tenant admin is emailed. Re-enabling it offers a replay of the missed window. |
| Circuit breaker | After 20 consecutive failures, delivery to that subscription is paused for 15 minutes so one broken receiver can't consume the worker pool. |
| Ordering | Not guaranteed. Use occurredAt and data.aggregateVersion. |
| Delivery semantics | At least once. Receivers must be idempotent on Spyrl-Event-Id. |
| Fairness | Per-tenant concurrency limit of 4 on q.webhooks; one tenant's slow receiver never delays another tenant |
3.4 Network safety (SSRF)
- HTTPS only, with a valid public certificate.
- The hostname is resolved at send time; private, loopback, link-local, carrier-grade NAT and cloud metadata ranges are rejected (checked again after DNS resolution to defeat rebinding).
- No redirects; a 10-second total timeout; a 256 KB response read limit.
- Egress goes through a dedicated NAT with published static IPs that clients can allow-list.
4. Subscription API
All endpoints need iam.api_client.manage and are available on the internal API and the public API (/v1/ntf/webhooks). Webhooks are a Professional-plan entitlement and above.
| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | /ntf/webhook-event-types | Subscribable event types with JSON Schemas and versions | Generated from the zod schemas |
| GET / POST | /ntf/webhooks | List subscriptions; create one | POST returns the signing secret once |
| GET | /ntf/webhooks/:id | Subscription detail and health | Success rate, last failure |
| PATCH | /ntf/webhooks/:id | Change URL, event types or description | A URL change re-runs the SSRF checks |
| DELETE | /ntf/webhooks/:id | Delete a subscription | |
| POST | /ntf/webhooks/:id/rotate-secret | Issue a new secret; the old one stays valid for 24 h | Returns the new secret once |
| POST | /ntf/webhooks/:id/enable | Re-enable a paused or auto-disabled subscription | Optional replayFrom |
| POST | /ntf/webhooks/:id/test | Send an ntf.webhook.tested delivery | |
| GET | /ntf/webhooks/:id/deliveries | Delivery log (status, attempts, response code, duration) | 30-day window; filter by status, eventType |
| POST | /ntf/webhooks/:id/deliveries/:deliveryId/replay | Replay one delivery | |
| POST | /ntf/webhooks/:id/replays | Replay a time range or all dead-lettered deliveries | Job resource |
Create a subscription:
POST /v1/ntf/webhooks
Host: api.spyrl.app
Authorization: Bearer <client-credentials token>
Idempotency-Key: 0193f000-0000-7000-8000-00000000d001
Content-Type: application/json
{
"url": "https://hr-sync.client.example/hooks/spyrl",
"description": "ERP employee and leave sync",
"eventTypes": ["emp.employee.joined", "emp.job.changed", "emp.employee.separated", "lve.request.approved", "lve.request.cancelled", "pay.run.locked"]
}
201 Created:
{
"id": "0193f001-1111-7222-8333-444455556666",
"url": "https://hr-sync.client.example/hooks/spyrl",
"eventTypes": ["emp.employee.joined", "emp.job.changed", "emp.employee.separated", "lve.request.approved", "lve.request.cancelled", "pay.run.locked"],
"status": "ACTIVE",
"secret": "whsec_7Hc2...shown-once...",
"createdAt": "2026-11-04T06:00:00Z",
"egressIps": ["203.0.113.10", "203.0.113.11"]
}
Subscribing to an event that isn't webhook-exposed returns 422 with code ntf.event_not_subscribable. Events are only delivered for data the API client's own scopes could read; for example, pay.* events need a client holding pay.run.review.
5. Open points
- Decision pending: whether
att.punch.recordedstays webhook-exposed at scale, or is replaced by a batchedatt.punches.exportedfile feed for high-volume tenants. - Decision pending: a CloudEvents-structured webhook mode (
application/cloudevents+json) for clients that use event gateways.