Skip to main content

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 example att.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​

EventPayload summaryProducerConsumersWebhook
org.location.createdlocationId, code, name, state, timezoneORGATT (fences), PAY (PT/LWF state), RPTYes
org.location.updatedlocationId, changed field namesORGATT, PAY, RPTYes
org.geofence.changedlocationId, geofenceId, changeORGATT, MOB (cached fences)No
org.holiday.changedcalendarId, dates[], change (ADDED, REMOVED, UPDATED)ORGATT (recompute), LVE (re-quote future requests), NTFYes
org.department.deactivateddepartmentIdORGEMPYes
org.designation.deactivateddesignationIdORGEMPYes
org.legal_entity.updatedlegalEntityId, changed field namesORGPAY (statutory registrations)No
org.account.createdaccountId, name, accountType, ownerEmployeeIdORGFFM, TSMYes
org.account.updatedaccountId, changed field namesORGFFM, TSMYes
iam.user.inviteduserId, employeeId, channelIAMNTF (send invite)No
iam.user.activateduserId, employeeIdIAMEMP (onboarding task), NTFYes
iam.user.lockeduserId, reasonIAMNTF (user and admin)No
iam.user.deactivateduserId, employeeId, reasonIAMMOB (wipe tokens), NTFYes
iam.role.changedroleId, changeTypeIAMPermission cache, auditNo
iam.role_assignment.changeduserId, roleId, scopeIAMPermission cache, auditNo
iam.device.approval_requesteddeviceId, userId, platformIAMNTF (manager task)No
iam.device.approveddeviceId, userIdIAMMOBNo
iam.device.revokeddeviceId, userIdIAMMOBNo
iam.session.revokedsessionId, userId, reasonIAMMOB, webNo
emp.employee.hiredemployeeId, employeeCode, joiningDate, locationId, designationIdEMPNTF (pre-boarding link), RPTYes
emp.preboarding.submittedemployeeIdEMPNTF (HR task)No
emp.employee.joinedemployeeId, employeeCode, joiningDate, locationId, departmentId, designationId, managerEmployeeId, employmentTypeEMPIAM (create user, invite), ATT, LVE (pro-rata accrual), PAY, LMS, ENGYes
emp.job.changedemployeeId, validFrom, changeReason, changed dimensions (location, department, designation, grade, manager)EMPATT, LVE, PAY, IAM (team scope), RPTYes
emp.employee.confirmedemployeeId, confirmationDateEMPLVE (eligibility), NTFYes
emp.document.submitteddocumentId, employeeId, docTypeEMPNTFNo
emp.document.approveddocumentId, employeeId, docTypeEMPNTFNo
emp.document.rejecteddocumentId, employeeId, docTypeEMPNTFNo
emp.change_request.approvedchangeRequestId, employeeId, sectionEMPPAY (bank change for the next run), NTFNo
emp.task.overduetaskId, employeeId, contextEMPNTF (reminder, escalation)No
emp.onboarding.completedemployeeIdEMPNTF, RPTYes
emp.import.committedbatchId, importType, committedCountEMPRPT, IAM (bulk invites)No
emp.employee.separatedemployeeId, lastWorkingDate, separationTypeEMPAll modules (closing)Yes

2.2 Attendance, leave and mobile​

EventPayload summaryProducerConsumersWebhook
att.punch.recordedpunchId, employeeId, punchedAt, direction, source, geofenceResultATTATT day computer, FFM (track session)Yes (high volume; opt-in)
att.punch.rejectedemployeeId, source, reasonCodeATTNTF (notify employee), RPTNo
att.day.computedemployeeId, workDate, status, firstHalf, secondHalf, paidFraction, workedMinutes, lateMinutes, otMinutesATTLVE (comp-off eligibility), PAY (input invalidation), RPTNo (use att.period.locked, then the API)
att.day.needs_actionemployeeId, workDate, reason (MISSED_PUNCH, UNEXPLAINED_ABSENCE)ATTNTF (employee alert)No
att.request.submittedrequestId, employeeId, type, subType, fromDate, toDateATTNTF (start workflow)No
att.request.approvedas submitted + onBehalfATTATT recompute, NTFYes
att.request.rejectedrequestId, employeeId, typeATTNTFNo
att.request.cancelledrequestId, employeeId, type, fromDate, toDateATTATT recomputeYes
att.overtime.approvedemployeeId, workDate, approvedMinutes, compensationModeATTPAY (OT input), LVE (comp-off mode)No
att.late.threshold_breachedemployeeId, periodStart, periodEnd, lateCount, deductDaysATTLVE (LATE_DEDUCTION), NTFNo
att.period.lockedperiodLockId, periodStart, periodEnd, payPeriodIdATTPAY (snapshot inputs), NTFYes
att.period.unlockedperiodLockId, reasonATTPAY (invalidate the draft run)Yes
att.adjustment.createdadjustmentId, employeeId, workDate, paidFractionDelta, otMinutesDeltaATTPAY (next-run input)No
lve.request.submittedrequestId, employeeId, leaveTypeCode, fromDate, toDate, chargedDaysLVENTF (start workflow)No
lve.request.approvedas submitted + onBehalfLVEATT (leave marks, recompute), NTF, calendar sync (R3)Yes
lve.request.rejectedrequestId, employeeId, leaveTypeCodeLVENTFYes
lve.request.withdrawnrequestId, employeeIdLVENTFNo
lve.request.cancellation_requestedrequestId, employeeIdLVENTF (cancellation workflow)No
lve.request.cancelledrequestId, employeeId, fromDate, toDateLVEATT (recompute), PAY (adjustment if locked)Yes
lve.balance.accruedleaveTypeCode, periodKey, employeeCount (batched per tenant)LVERPTNo
lve.ledger.adjustedentryId, employeeId, leaveTypeCode, days, reasonLVENTF (inform employee), RPTNo
lve.comp_off.creditedcreditId, employeeId, workedDate, days, expiresOnLVENTFNo
lve.comp_off.expiringcreditId, employeeId, expiresOnLVENTF (reminder)No
lve.comp_off.expiredcreditId, employeeId, daysLVENTFNo
lve.cycle.closedcycleId, cycleEnd, counts of carry-forward, lapse and encashmentLVEPAY (encashment inputs), RPTYes
lve.encashment.approvedencashmentId, employeeId, leaveTypeCode, daysLVEPAY (one-time input)No
mob.sync.batch_receivedbatchId, installId, kind, itemCountMOBATT (punch ingest), FFM (points, visits)No
mob.kiosk.enrolledkioskId, locationIdMOBIAM (device token), auditNo
mob.face.enrolledemployeeId, consentIdMOBAudit, consent registerNo
mob.face.deletedemployeeId, reasonMOBAudit, consent registerNo
mob.app.version_blockedplatform, appVersionMOBOps metricsNo

2.3 Payroll, claims and exit​

EventPayload summaryProducerConsumersWebhook
pay.compensation.changedcompensationId, employeeId, validFrom, reason (no amounts)PAYPAY arrears calculator, NTF (revision letter), RPTNo
pay.input.createdinputId, employeeId, periodId, inputType, sourceModulePAYPAY run invalidationNo
pay.run.createdrunId, periodId, runTypePAYRPTNo
pay.run.calculatedrunId, calcVersion, employeeCount, errorCount, warningCountPAYNTF (preparer), RPTNo
pay.run.submittedrunIdPAYNTF (approver task)No
pay.run.approvedrunId, approvedByPAYNTFYes
pay.run.lockedrunId, periodId, runType, employeeCount, lockedAtPAYATT (close period), CLM, EXT, NTF, accounting exportYes
pay.payslip.publishedrunId, employeeIds[] (batched), publishedAtPAYNTF (email, push)Yes
pay.statutory_file.generatedfileId, runId, scheme, statePAYNTF, RPTYes
pay.bank_file.generatedbankFileId, runId, bankFormat, rowCountPAYNTFYes
pay.run.paidrunId, paidAtPAYCLM, EXT, NTFYes
pay.run.reversedrunId, reversalRunIdPAYATT, CLM, NTFYes
pay.declaration.submitteddeclarationId, employeeId, financialYearPAYNTF (payroll task)No
pay.loan.approvedloanId, employeeId, loanTypePAYNTFNo
clm.claim.submittedclaimId, claimNo, employeeId, lineCountCLMNTF (workflow)No
clm.claim.verifiedclaimId, verifiedByCLMNTFNo
clm.claim.approvedclaimId, claimNo, employeeId, approvedPaise, payoutModeCLMPAY (input when paid through payroll), NTFYes
clm.claim.rejectedclaimId, reasonCLMNTFYes
clm.claim.sent_backclaimId, reasonCLMNTFNo
clm.claim.paidclaimId, paidAt, payoutBatchIdCLMNTF, RPTYes
clm.advance.approvedadvanceId, employeeId, amountPaiseCLMPAY or payout batchNo
clm.duplicate.suspectedclaimId, lineId, matchedLineIdCLMNTF (finance)No
ext.separation.submittedseparationId, employeeId, requestedLwdEXTNTF (workflow)No
ext.separation.approvedseparationId, employeeId, approvedLwd, separationTypeEXTEMP, LVE, ATT, FFM, NTFYes
ext.separation.rejectedseparationId, employeeIdEXTEMP, NTFNo
ext.separation.withdrawnseparationId, employeeIdEXTEMP, NTFYes
ext.termination.recordedseparationId, employeeId, effectiveDate, immediateRevokeEXTEMP, IAM, NTFYes
ext.clearance.completedseparationIdEXTEXT (F&F readiness)No
ext.lwd.reachedemployeeId, lastWorkingDateEXTIAM (deactivate), EMP, ATT, MOBYes
ext.fnf.initiatedsettlementId, separationId, employeeIdEXTPAY (FNF run), LVE (encashment), CLM (settle)No
ext.fnf.approvedsettlementId, employeeIdEXTNTFNo
ext.fnf.paidsettlementId, employeeId, paidAtEXTNTF (statement, letters)Yes

2.4 Field force, timesheets, learning, engagement and reporting​

EventPayload summaryProducerConsumersWebhook
ffm.session.startedsessionId, employeeId, modeFFMMOB (tracking on), RPTNo
ffm.session.endedsessionId, employeeIdFFMMOB (tracking off), RPTNo
ffm.visit.checked_invisitId, employeeId, accountId, checkedInAt, locationResultFFMATT (optional auto-punch), NTFYes
ffm.visit.completedvisitId, durationMinutesFFMRPTYes
ffm.opportunity.stage_changedopportunityId, accountId, fromStageId, toStageIdFFMRPT, NTFYes
ffm.opportunity.wonopportunityId, accountId, finalValuePaiseFFMNTF, RPTYes
ffm.payment.collectedpaymentId, opportunityId, amountPaise, methodFFMNTF (finance), RPTYes
ffm.travel.day_closeddailyTravelId, employeeId, workDate, computedMFFMNTF (employee review)No
ffm.travel.verifieddailyTravelId, employeeId, workDate, revisedMFFMCLM (mileage claim)No
tsm.timesheet.submittedtimesheetId, employeeId, weekStart, totalMinutesTSMNTF (workflow)No
tsm.timesheet.approvedtimesheetId, employeeId, weekStartTSMNTF, RPTYes
tsm.timesheet.rejectedtimesheetId, employeeIdTSMNTF, RPTNo
tsm.defaulter.detectedemployeeId, weekStartTSMNTFNo
tsm.period.lockedperiodStart, periodEndTSMRPT, billing exportYes
lms.enrollment.assignedenrollmentId, courseId, employeeId, dueDateLMSNTFNo
lms.enrollment.overdueenrollmentId, employeeIdLMSNTF (employee, then manager)No
lms.course.completedenrollmentId, courseId, employeeId, completedAt, scorePercentLMSEMP (checklist task), ENGYes
lms.certificate.issuedcertificateNo, enrollmentId, employeeIdLMSNTFYes
eng.announcement.publishedannouncementId, title, audienceCountENGNTFYes
eng.announcement.acknowledgedannouncementId, employeeIdENGRPTNo
eng.recognition.sentrecognitionId, fromEmployeeId, toEmployeeId, typeIdENGNTF (recipient and manager)No
eng.celebration.generatedemployeeId, kind, dateENGNTFNo
rpt.export.readyexportId, datasetKey, rowCount, expiresAtRPTNTF (in-app link)Yes
rpt.export.failedexportId, errorCodeRPTNTFNo
rpt.schedule.deliveredscheduleId, recipientCountRPTAuditNo

2.5 Workflow, notifications and platform​

EventPayload summaryProducerConsumersWebhook
ntf.instance.startedinstanceId, requestType, subjectId, requesterEmployeeIdNTFRPTNo
ntf.task.createdtaskId, instanceId, assigneeUserId, dueAtNTFNTF fan-out (approver notification), MOB badgeNo
ntf.task.remindedtaskId, assigneeUserIdNTFNTF fan-out, RPT (SLA)No
ntf.task.escalatedtaskId, fromUserId, toUserId, levelNTFNTF fan-out, RPT (SLA)No
ntf.instance.completedinstanceId, requestType, subjectId, outcomeNTFOwning module (in-process decision hook), RPTNo
ntf.message.failedmessageId, channel, provider, errorCodeNTFOps alertingNo
ntf.webhook.failedsubscriptionId, eventId, attempts, lastStatusCodeNTFTenant admin notification, DLQNo
ntf.webhook.testedsubscriptionIdNTFThe receiver only (test delivery)Yes (test only)
att.policy.published, lve.policy.published, pay.policy.published, clm.policy.publishedpolicyId, versionId, validFromOwning modulePolicy cache, att.range.recompute, RPTNo
plt.tenant.provisionedtenantId, cellId, planCodeControl planeApp cell seeding confirmation, NTF (welcome)No
plt.tenant.activatedtenantIdControl planeBilling, NTFNo
plt.tenant.suspendedtenantId, reasonControl planeApp plane (read-only mode), NTFNo
plt.tenant.reactivatedtenantIdControl planeApp planeNo
plt.tenant.churnedtenantId, exportDeadlineControl planeExport job, NTFNo
plt.tenant.deletedtenantIdControl planeCell purge job, deletion certificateNo
plt.entitlements.changedtenantId, featureKeys[]Control planeEntitlement SDK cache, MOB navigation refreshNo
plt.flag.changedflagKey, targetsControl planeFlag SDK cacheNo
plt.statutory_pack.publishedpackVersion, validFrom, schemes[]Control planePAY (new rate versions), NTF (payroll managers)No
plt.support_access.grantedgrantId, tenantId, accessMode, expiresAtControl planeIAM (temporary session), tenant audit logNo
plt.cell.version_changedcellId, versionControl planeRelease notes, monitoringNo

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 any v1 matches.
  • 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​

RuleDetail
SuccessAny 2xx within 10 seconds. Redirects are not followed and count as failures.
Retries12 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 queueAfter the last attempt the delivery becomes DEAD_LETTERED, ntf.webhook.failed is raised, and tenant admins are notified. Dead-lettered deliveries stay replayable.
ReplayFrom 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-disableAfter 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 breakerAfter 20 consecutive failures, delivery to that subscription is paused for 15 minutes so one broken receiver can't consume the worker pool.
OrderingNot guaranteed. Use occurredAt and data.aggregateVersion.
Delivery semanticsAt least once. Receivers must be idempotent on Spyrl-Event-Id.
FairnessPer-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.

MethodPathPurposeNotes
GET/ntf/webhook-event-typesSubscribable event types with JSON Schemas and versionsGenerated from the zod schemas
GET / POST/ntf/webhooksList subscriptions; create onePOST returns the signing secret once
GET/ntf/webhooks/:idSubscription detail and healthSuccess rate, last failure
PATCH/ntf/webhooks/:idChange URL, event types or descriptionA URL change re-runs the SSRF checks
DELETE/ntf/webhooks/:idDelete a subscription
POST/ntf/webhooks/:id/rotate-secretIssue a new secret; the old one stays valid for 24 hReturns the new secret once
POST/ntf/webhooks/:id/enableRe-enable a paused or auto-disabled subscriptionOptional replayFrom
POST/ntf/webhooks/:id/testSend an ntf.webhook.tested delivery
GET/ntf/webhooks/:id/deliveriesDelivery log (status, attempts, response code, duration)30-day window; filter by status, eventType
POST/ntf/webhooks/:id/deliveries/:deliveryId/replayReplay one delivery
POST/ntf/webhooks/:id/replaysReplay a time range or all dead-lettered deliveriesJob 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.recorded stays webhook-exposed at scale, or is replaced by a batched att.punches.exported file feed for high-volume tenants.
  • Decision pending: a CloudEvents-structured webhook mode (application/cloudevents+json) for clients that use event gateways.