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. The delivery log, manual redelivery and re-enabling a disabled subscription are API calls. Delivery is best effort with a bounded retry budget, not a guaranteed queue — read Delivery, retries and duplicates before you build on it.
Plans and access
Webhooks are a Growth and Enterprise plan feature. On other plans no event is ever dispatched, and the routes that create or change a subscription answer 403 Forbidden with the message Your FREE plan does not include webhooks. Upgrade at /admin/billing. (FREE stands for your own plan). There is no separate upgrade link field; the path is inside the message text.
These routes check your plan. Everything else works on any plan:
POST /webhooksPATCH /webhooks/:idPOST /webhooks/:id/rotatePOST /webhooks/:id/testPOST /webhooks/deliveries/:deliveryId/redeliver
A 403 also comes back when your role is not allowed to use these routes (see below), so tell the two apart by the message, not by the status code.
Endpoints
JWT-authenticated, and restricted to TENANT_OWNER and COMPANY_ADMIN (a TENANT_GROUP_OWNER passes every role check as well). Every other role gets a 403 on every route below, the read-only ones included: subscriptions can expose payroll and disciplinary events.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /webhooks | Bearer JWT | List this tenant's subscriptions as an array, newest first. Secrets are masked. |
| GET | /webhooks/events | Bearer JWT | List every valid event code, as an events array (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. Setting active to true also resets the failure counter. |
| DELETE | /webhooks/:id | Bearer JWT | Delete a subscription together with its delivery log. |
| POST | /webhooks/:id/rotate | Bearer JWT | Rotate the HMAC secret. The old secret stops verifying immediately. |
| POST | /webhooks/:id/test | Bearer JWT | Send one webhook.test event to your receiver and return the result inline. Never retried, never logged. |
| GET | /webhooks/:id/deliveries | Bearer JWT | Delivery log for one subscription, newest first (status, attempts, last error). Optional ?status (PENDING, DELIVERED, FAILED or DEAD) and ?limit (default 50, at most 200). There is no paging. |
| GET | /webhooks/deliveries/dead-count | Bearer JWT | Count of DEAD deliveries (all retry attempts used up) across all of this tenant's subscriptions. |
| POST | /webhooks/deliveries/:deliveryId/redeliver | Bearer JWT | Send one delivery again right now, whatever its status. A DELIVERED delivery is sent again too. |
POST /webhooks
{
"name": "Slack alerts",
"url": "https://example.com/webhooks/frontelio",
"events": ["leave.requested", "leave.approved", "leave.rejected"]
}name— 3 to 120 characters.url— must start withhttps://, up to 2000 characters.events— 1 to 50 event codes from the catalog below.
{
"id": "6e0c8f5a-2b7d-4c19-9a3e-5d1f7b8c4e20",
"tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"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,
"lastSuccessAt": null,
"lastFailureAt": null,
"lastFailureReason": null,
"createdAt": "2026-08-28T09:14:02.000Z",
"updatedAt": "2026-08-28T09:14:02.000Z"
}Errors you can expect from the management routes:
400— a validation failure: a name under 3 characters, a url that is nothttps://, an emptyeventslist, or an unknown event code.403— the plan or the role, as described above.404— no such subscription or delivery in your tenant.422— the url failed the egress check (below).
url must be https://. We check that it does not point at a private / link-local / cloud-metadata address when you create the subscription, when you change the url, and before every delivery attempt (SSRF egress guard). A hostname that does not resolve, or that resolves to a private address, is refused: with a 422 on create, update and test, and as a failed attempt on a real delivery. We do not follow redirects — a 3xx response from your endpoint is recorded as a failed delivery, not followed.
Other responses
{
"events": ["user.created", "user.terminated", "shift.assigned", "..."]
}{
"id": "6e0c8f5a-2b7d-4c19-9a3e-5d1f7b8c4e20",
"secret": "whsec_8d41b7... <-- the new secret, shown ONCE"
}{
"deleted": true,
"id": "6e0c8f5a-2b7d-4c19-9a3e-5d1f7b8c4e20"
}{
"deliveries": [
{
"id": "3b9d1f6e-8c24-4a70-b5e1-90a7c2d4f831",
"tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"webhookId": "6e0c8f5a-2b7d-4c19-9a3e-5d1f7b8c4e20",
"eventType": "leave.approved",
"payload": {
"tenantId": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"leaveRequestId": "f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f8a9b0c",
"approverUserId": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b"
},
"status": "FAILED",
"attempts": 2,
"maxAttempts": 6,
"nextAttemptAt": "2026-08-28T09:16:02.000Z",
"lastError": "upstream timed out",
"lastResponseStatus": 504,
"createdAt": "2026-08-28T09:14:02.000Z",
"updatedAt": "2026-08-28T09:14:35.000Z",
"deliveredAt": null
}
],
"count": 1
}count is the number of rows in this response, not a total. The log returns the newest rows only, up to ?limit; older rows cannot be reached, so read it regularly rather than as an archive.
{
"dead": 2
}{
"id": "3b9d1f6e-8c24-4a70-b5e1-90a7c2d4f831",
"requeued": true,
"status": "DELIVERED"
}status is the outcome of the immediate attempt: DELIVERED, FAILED or DEAD.
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 (the webhook.test event is only ever sent by the test route and cannot be subscribed to). Expand Sample payload under any event to see the wire body your receiver will get; ids and times in the samples are examples. Each description says what really fires the event, and several events fire from fewer places than their name suggests, so read them before you rely on one. The catalog is also served machine-readable (see Using an API key) so Zapier and n8n integrations can map fields before any real event has fired.
Users
user.created— a user is created withPOST /usersor provisioned through SCIM (POST /scim/v2/Users). Users created by bulk import, by first-time SSO sign-in (just-in-time provisioning) or by converting a hired candidate in the hiring pipeline do not fire it.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 is deleted withDELETE /users/:id(or SCIMDELETE /scim/v2/Users/:id), which soft-deletes the account. Deactivating a user (PATCH /users/:id/status, or a SCIMactive: false), completing an offboarding and the intern-expiry job all set the user to INACTIVE without firing it. To hear about an exit, subscribe tooffboarding.settled.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 assignment is created, one at a time (POST /shift-assignments) or in bulk when a roster is published (POST /scheduler/assignments, andPOST /public/shiftson the public API). On a tenant that requires roster approval, the published rows are drafts (PROPOSED) but still fire this event; the payload has no status field, and approving or rejecting the roster fires nothing, so a rejected draft is never retracted. Re-assigning a shift, cloning a week, approving a shift swap and accepting an AI schedule do not fire it.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 worker clocks out of an assigned shift (POST /mobile/clock-out). A shift closed by the missed-clock-out safety net, or set to COMPLETED by a manager withPATCH /shift-assignments/:id, does not fire it.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 by the last step of its approval chain.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. One rejection ends the chain.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— an issue is raised withPOST /issuesor the mobile report-issue endpoint. Issues created any other way — auto-flagged failed checklist tasks, maintenance-due checks, an asset's quick-issue action, workflow automations and unresolved troubleshooting — do not fire it.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 moved to RESOLVED, CLOSED or CANCELLED withPATCH /issues/:id;payload.statussays which. Other statuses, including VERIFIED, do not fire it, nor does re-submitting the status an issue already has, nor an issue resolved as a side effect of a verified work order or a logged asset service.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 a course they are enrolled in: they pass its quiz, or finish the last lesson of a course that has no quiz.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. New contracts start as DRAFT, sopayload.statusisDRAFT; activating a contract later fires no event, and neither does a contract created ACTIVE by bulk staff import.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": "DRAFT", "startDate": "2026-09-01", "endDate": null } }contract.updated— an HR contract is edited (e.g. title or terms change). Salary is deliberately never included. Activating a contract fires no event, although it changes the status and terminates the previous active contract.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 is terminated with the Terminate action. Adds areasonfield. It is not fired when an offboarding completes (seeoffboarding.settled), when activating a newer contract supersedes the old one, or when the daily job marks an overdue contract EXPIRED.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 (POST /offboarding/:id/finalize). The active contract is terminated and the user set to INACTIVE at the same moment, but neithercontract.endednoruser.terminatedfires — this is the one event to use for exits. 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": "7f0a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"occurredAt": "2026-08-28T09:14:02.000Z",
"payload": {
"...": "event-specific fields"
}
}event— the event code.tenantId— the UUID of the tenant that owns the subscription. Always present. Some event payloads repeat it insidepayload;shift.released,shift.claimedandwebhook.testpayloads do not, so read it from the envelope.occurredAt— an ISO 8601 UTC time, fixed when the event was dispatched. Retries and manual redeliveries carry the original value, so you can order events by it even when a retry arrives late. Forwebhook.testit is the time of the test.payload— the event-specific fields shown in the samples.
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.X-Frontelio-Delivery: <uuid>— the id of this delivery, the same one the delivery log shows. It is identical on every attempt, retries and manual redeliveries included, so use it to de-duplicate. Not sent by the test route, which has no delivery.User-Agent: Frontelio-Webhook/1.0
Delivery, retries and duplicates
Your endpoint has 15 seconds to respond with a 2xx. Anything else is a failed attempt: a non-2xx status, a redirect (never followed), a timeout, a connection error, or a url that fails the egress check when we send (for example a hostname that no longer resolves).
A real event is sent once immediately. If that fails we retry up to five more times, six attempts in total:
attempt 1 immediately, when the event fires
attempt 2 30 s after attempt 1 fails
attempt 3 60 s after attempt 2 fails
attempt 4 120 s after attempt 3 fails
attempt 5 240 s after attempt 4 fails
attempt 6 480 s after attempt 5 fails
then the delivery is DEADEach wait varies by up to 10% either way. Retries are picked up by a sweep that runs every 2 minutes, so a retry starts on the first sweep after it falls due; in practice the sixth attempt is made roughly 16 to 25 minutes after the first one failed, and 20 or so is typical.
A delivery that fails its sixth attempt is DEAD. It is kept, not deleted: it stays in the delivery log and in dead-count, and you can send it again with redeliver once your endpoint is fixed. A redelivery is one immediate attempt and does not reset the attempt count, so a DEAD delivery that fails again goes straight back to DEAD. The log records, per delivery, status, attempts, nextAttemptAt, lastResponseStatus (0 when there was no HTTP response) and lastError (the first 500 characters of your response body, or HTTP <status> when the body was empty, or the error message when there was no response).
Auto-disable
Every failed attempt — an initial delivery, a retry, a redelivery or a /test call — adds one to the subscription's failureCount, and any success resets it to 0. After 10 consecutive failed attempts we set active to false and keep the reason in lastFailureReason. The count adds up across all of the subscription's events, so one event's six failed attempts plus four from other events are enough (attempts that fail at the very same moment can be counted once, which can delay it a little).
Nothing is emailed when this happens. While a subscription is inactive we send it nothing and create no deliveries for it, so events that fire in that window are lost for good: they are not in the delivery log or in dead-count, and they cannot be redelivered. Deliveries that were already queued keep their retry schedule. Re-enable a subscription with PATCH /webhooks/:id {"active": true}, which also resets failureCount to 0. A test or a redelivery, successful or not, never switches a subscription back on. Poll GET /webhooks and watch active and failureCount if you depend on a subscription staying up.
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");
// A delivery can arrive more than once: skip ones you already handled.
const deliveryId = req.header("X-Frontelio-Delivery");
// if (await alreadyProcessed(deliveryId)) return res.sendStatus(200);
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 send a webhook.test event to your endpoint. Its payload is { "message": "...", "testedBy": "<your user id>" }. The request is made synchronously and your receiver's answer comes back inline, so you immediately see whether it accepted the event. The test is sent whatever the subscription's event list, it is not retried and it does not appear in the delivery log.
{
"ok": true,
"status": 200,
"bodyExcerpt": ""
}If your endpoint fails, you still get a 201 with ok set to false. status is the HTTP status you returned, or 0 when there was no response, and bodyExcerpt is the first 500 characters of your response body or the error message. A failed test counts toward auto-disable. A url that fails the egress check answers 422 instead.
{
"ok": false,
"status": 500,
"bodyExcerpt": "Internal Server Error"
}Using an API key instead of a user login
Integrations that should not hold a person's login (Zapier, n8n and similar) can manage subscriptions with an API key on the public API, under https://api.frontelio.com/api/v1: GET /public/webhooks with scope webhooks:read, and POST /public/webhooks, PATCH /public/webhooks/:id, DELETE /public/webhooks/:id and POST /public/webhooks/:id/rotate with scope webhooks:write. There is no test route and no delivery log there.
The event catalog is served at GET /public/webhook-catalog, with no scope. Every /public route, the catalog included, needs the Enterprise plan, the Public Developer API switch turned on under Settings → Labs, and a credential: an fco_live_ API key, or an fco_oauth_ access token, sent as Authorization: Bearer (the X-API-Key header accepts static keys only). Without a credential you get 401; without the plan or the Labs switch, 403. See the API Reference for keys and scopes.
See also
For the outlets / employees / shifts / attendance / leave REST endpoints — the resources most webhook payloads reference — see the API Reference. user.created and user.terminated report changes made inside Frontelio. To go the other way and have your identity provider create and deactivate users in Frontelio, see SCIM Provisioning; users it creates or deletes fire those two events, but a SCIM active: false deactivation fires no event at all.