Subscribe to Frontelio events.
Get an HTTP POST the moment something happens in your tenant — a shift is assigned, a leave request is approved, a payroll run is finalized. Every delivery is a signed JSON body you can verify with a shared secret.
Manage webhook subscriptions from Settings → Developer in the app, or directly against the API below. Webhooks are a Growth and Enterprise plan feature — on lower plans these endpoints return 402 Payment Required with a link to upgrade, and no event is ever dispatched for a tenant without the feature.
Endpoints
JWT-authenticated, owner-tier (TENANT_OWNER, COMPANY_ADMIN, OPERATIONS_MANAGER).
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /webhooks | Bearer JWT | List this tenant's webhook subscriptions. Secrets are masked. |
| GET | /webhooks/events | Bearer JWT | List every valid event code (the same list documented below). |
| POST | /webhooks | Bearer JWT | Create a subscription. The full HMAC secret is returned ONCE. |
| PATCH | /webhooks/:id | Bearer JWT | Update the name, url, events, or active flag. |
| DELETE | /webhooks/:id | Bearer JWT | Delete a subscription. |
| POST | /webhooks/:id/rotate | Bearer JWT | Rotate the HMAC secret. The old secret stops verifying immediately. |
| POST | /webhooks/:id/test | Bearer JWT | Fire a dummy webhook.test event at your receiver, synchronously, so you can confirm it's wired up. |
| GET | /webhooks/:id/deliveries | Bearer JWT | Paginated delivery log for one subscription (status, attempts, last error). |
| GET | /webhooks/deliveries/dead-count | Bearer JWT | Count of deliveries that exhausted all retry attempts. |
| POST | /webhooks/deliveries/:deliveryId/redeliver | Bearer JWT | Manually retry one dead or failed delivery. |
POST /webhooks
{
"name": "Slack alerts",
"url": "https://example.com/webhooks/frontelio",
"events": ["leave.requested", "leave.approved", "leave.rejected"]
}{
"id": "wh_9f2a...",
"name": "Slack alerts",
"url": "https://example.com/webhooks/frontelio",
"events": ["leave.requested", "leave.approved", "leave.rejected"],
"secret": "whsec_2c9f1e... <-- shown ONCE, store it now",
"active": true,
"failureCount": 0,
"createdAt": "2026-08-28T09:14:02.000Z"
}url must be https://. We validate it isn't pointed at a private / link-local / cloud-metadata address at both create time and every delivery (SSRF egress guard), and we do not follow redirects — a 3xx response from your endpoint is recorded as a failed delivery, not followed.
Event catalog
Twenty event codes, grouped by domain. Pass the ones you want in events on create/update — a subscription only receives events it explicitly lists. Expand Sample payload under any event to see the exact wire body your receiver will get (also served machine-readable at GET /public/webhook-catalog on the public API, so Zapier/n8n integrations can map fields before any real event has fired).
Users
user.created— a new user account is created in this tenant.Sample payload
{ "event": "user.created", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "email": "sara.hassan@example.com", "fullName": "Sara Hassan" } }user.terminated— a user's employment ends and their account is offboarded.Sample payload
{ "event": "user.terminated", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" } }
Shifts
shift.assigned— a shift is assigned to a staff member.Sample payload
{ "event": "shift.assigned", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "shiftAssignmentId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "outletId": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "scheduledStart": "2026-08-29T08:00:00.000Z", "scheduledEnd": "2026-08-29T16:00:00.000Z" } }shift.completed— a shift is completed (clocked out or manager-closed).Sample payload
{ "event": "shift.completed", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "shiftAssignmentId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "outletId": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "scheduledStart": "2026-08-28T08:00:00.000Z", "scheduledEnd": "2026-08-28T16:00:00.000Z", "clockOutAt": "2026-08-28T16:04:12.000Z", "attendanceEventId": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a", "status": "VERIFIED" } }shift.released— an assigned shift is released back to the open-shifts marketplace.Sample payload
{ "event": "shift.released", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "shiftId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "outletId": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "scheduledStart": "2026-08-30T08:00:00.000Z", "scheduledEnd": "2026-08-30T16:00:00.000Z", "type": "REGULAR", "previousUserId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "releasedByUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }shift.claimed— an eligible staff member claims a released open shift.Sample payload
{ "event": "shift.claimed", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "shiftId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "outletId": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "scheduledStart": "2026-08-30T08:00:00.000Z", "scheduledEnd": "2026-08-30T16:00:00.000Z", "type": "REGULAR", "claimedByUserId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" } }
Leave
leave.requested— a staff member submits a leave request.Sample payload
{ "event": "leave.requested", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "leaveRequestId": "f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f8a9b0c", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "leaveType": "ANNUAL", "startDate": "2026-09-06T00:00:00.000Z", "endDate": "2026-09-10T00:00:00.000Z", "days": 4, "daysDecimal": 4 } }leave.approved— a leave request is approved.Sample payload
{ "event": "leave.approved", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "leaveRequestId": "f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f8a9b0c", "approverUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }leave.rejected— a leave request is rejected.Sample payload
{ "event": "leave.rejected", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "leaveRequestId": "f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f8a9b0c", "rejecterUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b", "note": "Peak week — please pick dates after the 15th." } }
Issues
issue.created— a new issue is raised.Sample payload
{ "event": "issue.created", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "issueId": "a7b8c9d0-e1f2-4a3b-4c5d-6e7f8a9b0c1d", "outletId": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "priority": "HIGH", "title": "Walk-in freezer running warm", "reporterUserId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" } }issue.resolved— an issue is marked resolved.Sample payload
{ "event": "issue.resolved", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "issueId": "a7b8c9d0-e1f2-4a3b-4c5d-6e7f8a9b0c1d", "status": "RESOLVED", "resolutionNote": "Compressor relay replaced; holding at -19C.", "resolvedByUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }
Payroll
payroll.approved— a payroll run is approved. Money fields are integer minor units (fils/cents).Sample payload
{ "event": "payroll.approved", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "payrollRunId": "b8c9d0e1-f2a3-4b4c-5d6e-7f8a9b0c1d2e", "periodYearMonth": "2026-08", "currency": "AED", "totalGrossMinor": 18450000, "totalNetMinor": 17120000, "totalDeductionsMinor": 1330000, "payslipCount": 42, "approverUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }payroll.finalized— a payroll run is finalized (its GL journal becomes exportable). Fires alongsidepayroll.approvedwith the same payload — existing subscribers to that event are unaffected.Sample payload
{ "event": "payroll.finalized", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "payrollRunId": "b8c9d0e1-f2a3-4b4c-5d6e-7f8a9b0c1d2e", "periodYearMonth": "2026-08", "currency": "AED", "totalGrossMinor": 18450000, "totalNetMinor": 17120000, "totalDeductionsMinor": 1330000, "payslipCount": 42, "approverUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }payroll.paid— a payroll run is marked paid.Sample payload
{ "event": "payroll.paid", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "payrollRunId": "b8c9d0e1-f2a3-4b4c-5d6e-7f8a9b0c1d2e", "periodYearMonth": "2026-08", "currency": "AED", "totalGrossMinor": 18450000, "totalNetMinor": 17120000, "totalDeductionsMinor": 1330000, "paidAt": "2026-08-31T10:00:00.000Z", "paidByUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }
Training
course.completed— a staff member completes an assigned training course.Sample payload
{ "event": "course.completed", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "courseId": "c9d0e1f2-a3b4-4c5d-6e7f-8a9b0c1d2e3f", "courseTitle": "Food Safety Level 2", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "scorePct": 92, "attempts": 1 } }
Contracts
contract.created— a new HR contract is created for an employee.Sample payload
{ "event": "contract.created", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "contractId": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "jobTitle": "Barista", "employmentType": "FULL_TIME", "status": "ACTIVE", "startDate": "2026-09-01", "endDate": null } }contract.updated— an HR contract is updated (e.g. title or terms change). Salary is deliberately never included.Sample payload
{ "event": "contract.updated", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "contractId": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "jobTitle": "Senior Barista", "employmentType": "FULL_TIME", "status": "ACTIVE", "startDate": "2026-09-01", "endDate": null } }contract.ended— an HR contract ends (manual end or offboarding). Adds areasonfield.Sample payload
{ "event": "contract.ended", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "contractId": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "jobTitle": "Senior Barista", "employmentType": "FULL_TIME", "status": "TERMINATED", "startDate": "2026-09-01", "endDate": "2027-08-31", "reason": "Resignation" } }
Disciplinary
disciplinary.issued— a disciplinary action is issued to a staff member. Carries level + category only — never the free-text details.Sample payload
{ "event": "disciplinary.issued", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "disciplinaryActionId": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "level": "WRITTEN", "category": "ATTENDANCE", "autoEscalated": false, "issuedByUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }
Offboarding
offboarding.settled— an employee's final settlement (end-of-service dues) is completed. Carries ids + dates only — never settlement amounts.Sample payload
{ "event": "offboarding.settled", "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "occurredAt": "2026-08-28T09:14:02.000Z", "payload": { "tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "offboardingId": "f2a3b4c5-d6e7-4f8a-9b0c-1d2e3f4a5b6c", "userId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "reason": "RESIGNATION", "lastWorkingDay": "2026-09-30", "completedAt": "2026-09-30T14:00:00.000Z", "finalizedByUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b" } }
Envelope shape
Every delivery — real event or the /test dummy — POSTs this fixed shape:
{
"event": "leave.approved",
"tenantId": "ten_ab12...",
"occurredAt": "2026-08-28T09:14:02.000Z",
"payload": {
"...": "event-specific fields"
}
}Alongside the body, every delivery carries these headers:
Content-Type: application/jsonX-Frontelio-Event: leave.approved— the same value aseventin the body, for routing without a JSON parse.X-Frontelio-Signature: sha256=<hmac>— see verification below.User-Agent: Frontelio-Webhook/1.0
Your endpoint has 15 seconds to respond with a 2xx. A failure (non-2xx, timeout, or connection error) schedules a retry with backoff; a delivery that exhausts its retry budget becomes DEAD and shows up in GET /webhooks/deliveries/dead-count — redeliver it manually once your endpoint is fixed.
Verifying signatures
Every delivery is signed with HMAC-SHA256 over the raw JSON body, using the webhook's secret (the whsec_... value you saved when you created or last rotated the subscription). Verify it before trusting the payload:
- Read the raw request body as bytes — before any JSON parsing (whitespace changes the signature).
- Compute
HMAC-SHA256(secret, rawBody)as a hex digest. - Compare it, in constant time, against the part after
sha256=in theX-Frontelio-Signatureheader. - Reject the request (401/403) on any mismatch — don't process the payload.
const crypto = require("crypto");
function verifyFrontelioSignature(rawBody, signatureHeader, secret) {
// rawBody must be the exact bytes received — read it before your
// framework parses JSON (e.g. express.raw() on this route).
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const provided = (signatureHeader || "").replace(/^sha256=/, "");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b); // constant-time compare
}
// Example handler:
app.post(
"/webhooks/frontelio",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyFrontelioSignature(
req.body, // Buffer — raw bytes, unparsed
req.header("X-Frontelio-Signature"),
process.env.FRONTELIO_WEBHOOK_SECRET,
);
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event.event / event.payload ...
res.sendStatus(200);
},
);Testing your receiver
Use Settings → Developer in the app (or POST /webhooks/:id/test directly) to fire a webhook.test event at your endpoint synchronously — the response comes back inline so you immediately see whether your receiver accepted it, without waiting for a real event to happen.
{
"ok": true,
"status": 200,
"bodyExcerpt": ""
}See also
For the outlets / employees / shifts / attendance / leave REST endpoints — the resources most webhook payloads reference — see the API Reference. If you'd rather have your identity provider create and deactivate users automatically instead of listening for user.created / user.terminated, see SCIM Provisioning.