Browse docs
Webhooks

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).

MethodPathAuthDescription
GET/webhooksBearer JWTList this tenant's webhook subscriptions. Secrets are masked.
GET/webhooks/eventsBearer JWTList every valid event code (the same list documented below).
POST/webhooksBearer JWTCreate a subscription. The full HMAC secret is returned ONCE.
PATCH/webhooks/:idBearer JWTUpdate the name, url, events, or active flag.
DELETE/webhooks/:idBearer JWTDelete a subscription.
POST/webhooks/:id/rotateBearer JWTRotate the HMAC secret. The old secret stops verifying immediately.
POST/webhooks/:id/testBearer JWTFire a dummy webhook.test event at your receiver, synchronously, so you can confirm it's wired up.
GET/webhooks/:id/deliveriesBearer JWTPaginated delivery log for one subscription (status, attempts, last error).
GET/webhooks/deliveries/dead-countBearer JWTCount of deliveries that exhausted all retry attempts.
POST/webhooks/deliveries/:deliveryId/redeliverBearer JWTManually retry one dead or failed delivery.

POST /webhooks

POST /webhooks
{
  "name": "Slack alerts",
  "url": "https://example.com/webhooks/frontelio",
  "events": ["leave.requested", "leave.approved", "leave.rejected"]
}
201 Created
{
  "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.createda 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.terminateda 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.assigneda 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.completeda 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.releasedan 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.claimedan 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.requesteda 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.approveda 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.rejecteda 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.createda 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.resolvedan 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.approveda 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.finalizeda payroll run is finalized (its GL journal becomes exportable). Fires alongside payroll.approved with 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.paida 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.completeda 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.createda 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.updatedan 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.endedan HR contract ends (manual end or offboarding). Adds a reason field.
    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.issueda 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.settledan 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:

POST <your url>
{
  "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/json
  • X-Frontelio-Event: leave.approved — the same value as event in 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 the X-Frontelio-Signature header.
  • Reject the request (401/403) on any mismatch — don't process the payload.
Node.js
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.

POST /webhooks/:id/test → 201 Created
{
  "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.