Build on the Frontelio API.
A REST API over JSON, versioned in the path. Signed-in users authenticate with a Bearer JWT. Server-to-server integrations use a scoped developer API key or OAuth 2.0 client credentials. Door-reader bridges use their own key.
The web and mobile apps are clients of this same API, so a signed-in user can call any endpoint their role permits. Integrations that authenticate with a developer API key or an OAuth client get a narrower, purpose-built surface under /public (see Developer API keys and OAuth clients). Every key and every OAuth client belongs to one tenant and only ever reads or writes that tenant's data.
The URL carries the major version (/api/v1). We add endpoints and optional response fields without notice, so ignore fields you do not recognise. There is no formal deprecation schedule or complete changelog yet: the release highlights on the docs home are a hand-written list, not a changelog. If your integration depends on particular endpoints, tell support@frontelio.com which ones so we can take them into account.
Base URL
For all examples in these docs:
https://api.frontelio.com/api/v1Self-hosted installations will have their own base URL — but every example path below (e.g. /auth/login, /access/verify) is the suffix appended to your base.
Authentication
Send credentials in the Authorization header. There are four kinds of credential, and each is accepted only on the routes listed for it. A credential sent to a route that does not accept it is rejected with 401.
| Credential | Format | Use it for | Accepted on |
|---|---|---|---|
| Bearer JWT | Signed token returned by POST /auth/login | The web and mobile apps, and scripts that act as a signed-in user | Every route except the ones described below |
| Developer API key | fco_live_ followed by 43 characters | Server-to-server integrations (Enterprise plan) | The /public developer endpoints and /scim/v2 |
| OAuth 2.0 access token | fco_oauth_ followed by 43 characters, valid for 1 hour | The same routes as a developer key, for tools that use the client-credentials flow | The /public developer endpoints and /scim/v2 |
| Reader-bridge key | mk_ followed by 32 characters | Door-reader hardware | POST /access/verify and POST /access/reader-heartbeat only |
Signed-in users (Bearer JWT)
Exchange an identifier and password for a token pair. The identifier is the user's email address or mobile number. A body that uses email instead of identifier is rejected with 400.
{
"identifier": "owner@cafejoud.ae",
"password": "Winter-Roast-2026"
}{
"accessToken": "eyJhbGciOi...",
"refreshToken": "eyJhbGciOi...",
"accessExpiresIn": "15m",
"refreshExpiresIn": "7d",
"tokenType": "Bearer"
}Pass the access token as Authorization: Bearer <accessToken>. The lifetimes shown are the defaults; read accessExpiresIn from the response rather than hard-coding them. The mobile and web clients refresh automatically by calling POST /auth/refresh with the refresh token when a request returns 401. See Authentication for signup, refresh and invite redemption.
Developer API keys and OAuth clients
The developer API is a scoped, tenant-bound surface for integrations such as Zapier, n8n or your own back office. It lives under /public. Two conditions must hold on every call, and both are re-checked each time: the tenant is on the Enterprise plan, and the tenant has switched on the Public Developer API (Settings, Developer). If either stops being true, existing keys and tokens stop working on their next request.
SCIM provisioning at /scim/v2 uses the same key type with the scim:read and scim:write scopes. It needs the Enterprise single sign-on feature but not the Public Developer API switch. See SCIM Provisioning.
Create a key
A tenant owner or company admin creates a key in Settings, Developer, or by calling POST /api-keys while signed in. The full key is shown once and cannot be retrieved later. A key created without scopes receives outlets:read and employees:read; grant only the scopes an integration needs.
{
"name": "Back-office sync",
"scopes": ["outlets:read", "employees:read", "shifts:read"],
"expiresAt": "2027-03-01T00:00:00Z"
}{
"id": "0f6c1d2e-3a4b-4c5d-8e9f-a0b1c2d3e4f5",
"name": "Back-office sync",
"prefix": "fco_live_AbCd",
"key": "fco_live_<43 characters, shown once>",
"createdAt": "2026-09-20T08:00:00.000Z",
"expiresAt": "2027-03-01T00:00:00.000Z"
}Use a key
Send the key as Authorization: Bearer fco_live_... or in an X-API-Key header. Access tokens from the OAuth flow below are accepted only in the Authorization header.
curl "https://api.frontelio.com/api/v1/public/outlets?limit=2" \
-H "Authorization: Bearer fco_live_<your key>"{
"data": [
{
"id": "6d0b3a5e-1c2f-4a7b-9d8e-0f1a2b3c4d5e",
"name": "Joud Marina",
"code": "MRN-01",
"city": "Dubai",
"status": "ACTIVE",
"createdAt": "2026-05-04T06:30:00.000Z"
}
],
"pagination": { "limit": 2, "offset": 0, "count": 1 }
}Scopes
Every scope has the form resource:read or resource:write. The routes for outlets, employees and the webhook catalog need no particular scope: any valid key or token works there, whatever scopes it carries. Every other route needs the scope shown in the endpoint table below.
outlets:read,employees:readshifts:read,shifts:writeattendance:read,attendance:writeleave:read,leave:writewebhooks:read,webhooks:writescim:read,scim:write(SCIM only)
OAuth 2.0 client credentials
For tools that expect OAuth, register a client with POST /oauth-clients while signed in as a tenant owner or company admin (same plan and Developer API requirements as a key). The response contains a clientId and a clientSecret; the secret is shown once. Choose the scopes with allowedScopes: a client registered without them has none. Then exchange the credentials for a one-hour access token:
{
"grant_type": "client_credentials",
"client_id": "fco_client_<your client id>",
"client_secret": "fco_secret_<your client secret>"
}{
"access_token": "fco_oauth_<43 characters>",
"token_type": "Bearer",
"expires_in": 3600
}Send the token as Authorization: Bearer fco_oauth_.... Send the exchange body as JSON. A wrong client id, a wrong secret and a revoked client all return the same 401, so a failed exchange does not reveal which one it was:
{
"error": "invalid_client",
"error_description": "Unknown client, wrong secret, or the client has been revoked."
}A token has the scopes of the client that issued it. Revoking a client invalidates its outstanding tokens on their next use. POST /oauth/token is limited to 30 requests per minute per source IP address.
Developer API endpoints
All paths are relative to the base URL. The Auth column shows the scope the endpoint requires.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /public/outlets | Any valid key or token | List the tenant's outlets. |
| GET | /public/employees | Any valid key or token | List employees (id, full name, email, status). Supports order=newest. |
| GET | /public/webhook-catalog | Any valid key or token | Every webhook event with a sample payload. |
| GET | /public/shifts | shifts:read | List shifts. Filter by outletId, from and to. |
| POST | /public/shifts | shifts:write | Create one shift. |
| GET | /public/attendance | attendance:read | List attendance events. Filter by outletId, userId, status, from and to. |
| PATCH | /public/attendance/:id | attendance:write | Approve or reject an attendance event. Clock-in and clock-out are not available here. |
| GET | /public/leave-requests | leave:read | List leave requests. Filter by status, type, userId, from and to. |
| POST | /public/leave-requests | leave:write | Create one leave request. The body must name the person in targetUserId. |
| GET | /public/webhooks | webhooks:read | List webhook subscriptions. Secrets are masked. |
| POST | /public/webhooks | webhooks:write | Create a subscription. The signing secret is returned once. |
| PATCH | /public/webhooks/:id | webhooks:write | Change url, events or active. |
| DELETE | /public/webhooks/:id | webhooks:write | Revoke a subscription. |
| POST | /public/webhooks/:id/rotate | webhooks:write | Issue a new signing secret. The old one stops working at once. |
Event names, the delivery envelope and signature verification are on the Webhooks page. Reads are limited to 60 requests per minute and writes to 10 per minute (see Rate limits).
Reader-bridge keys
Door-reader bridges do not use a user or developer credential. An admin creates a reader-bridge key in /admin/access on the API keys tab. The full key is shown once. It is accepted on two routes and nowhere else: POST /access/verify and POST /access/reader-heartbeat. Send it as Authorization: Bearer mk_.... It is not a developer API key and cannot call the developer API. See Access endpoints and the reader bridge guide.
Routes that do not take a user JWT
Most of the API rejects a request without a valid credential. The routes below are the exceptions: they skip the JWT check. A few identify the caller in another way (a code or token in the path or body, a device token, an Apple pass token, or a signature from the calling provider); the rest serve public data. All except /health and /status are rate-limited per source IP address.
| Purpose | Routes | How the caller is identified |
|---|---|---|
| Sign-in and account | POST /auth/loginPOST /auth/refreshPOST /auth/logoutPOST /auth/forgot-passwordPOST /auth/reset-passwordPOST /auth/signupPOST /auth/invite/:code/redeem | Credentials, a refresh or reset token, or an invite code sent in the request. Signup and forgot-password need no prior credential |
| OAuth token exchange | POST /oauth/token | Client id and client secret in the body |
| Single sign-on (browser redirects) | GET /auth/sso/lookupPOST /auth/sso/startGET /auth/sso/callbackPOST /auth/sso/saml/acsGET /auth/microsoft/statusGET /auth/microsoft/startGET /auth/microsoft/callback | Not identified: these start or complete a browser sign-in with your identity provider |
| Door access | POST /access/visitors/:inviteCode/redeemPOST /access/webhooks/:integrationId/access/wallet/v1/* | A visitor code, the provider's signature header, or an Apple pass token (the wallet routes are called by Apple) |
| Kiosk devices | GET /kiosk/workersPOST /kiosk/clock | A kiosk device token as a Bearer credential |
| Links sent to people outside your team | GET /esign/public/:tokenGET /esign/public/:token/downloadPOST /esign/public/:token/signGET /careers/:tenantSlugGET /careers/:tenantSlug/jobs/:jobSlugPOST /careers/:tenantSlug/jobs/:jobSlug/applyPOST /careers/:tenantSlug/jobs/:jobSlug/apply/resumeGET /u/:uid | A signing token, or a public careers page or profile identifier, in the path |
| Provider callbacks | GET /whatsapp/webhookPOST /whatsapp/webhookGET /integrations/xero/callback | Called by WhatsApp and Xero: a verify token or signature, or the OAuth state value |
| Public data | POST /public/leadsGET /branding/publicGET /branding/logo/:tenantIdGET /app/configGET /healthGET /status | Not identified. The sales-enquiry form, login-page branding, app configuration and health checks |
POST /public/leads is the marketing site's enquiry form and shares the /public prefix with the developer API without being part of it. Internal platform-operator endpoints authenticate with their own secrets, are not available to tenants, and are not documented here.
Rate limits
Limits are fixed one-minute windows unless the table says otherwise, counted separately for every endpoint and every caller, using @nestjs/throttler. A caller is the signed-in user when the request carries a valid access JWT, so colleagues behind one office network do not share a budget. Every other request is counted by its source IP address: anonymous calls, and calls that use a developer API key, an OAuth token or a reader-bridge key. The default is 120 requests per minute per endpoint per caller. Stricter limits apply to sensitive endpoints:
| Endpoint | Limit | Window | Why |
|---|---|---|---|
| POST /auth/login | 30 | 60s | Brute-force defence |
| POST /auth/refresh | 30 | 60s | Replay protection |
| POST /auth/signup | 5 | 60s | Anti-spam new-tenant signups |
| POST /auth/forgot-password | 5 | 60s | Mail-bombing and enumeration defence |
| POST /auth/reset-password | 10 | 60s | Reset-token guessing defence |
| POST /auth/invite/:code/redeem | 10 | 15 min | Invite-code guessing defence |
| POST /access/visitors/:inviteCode/redeem | 5 | 5 min | Magic-link guessing defence |
| POST /oauth/token | 30 | 60s | Client-secret guessing defence |
| GET /public/* | 60 | 60s | Developer API reads |
| POST, PATCH, DELETE /public/* | 10 | 60s | Developer API writes |
| POST /users/bulk-import POST /outlets/bulk-import POST /vendors/bulk-import POST /assets/bulk-import POST /asset-categories/bulk-import | 6 | 60s | One call can write up to 500 rows |
| POST /api-keys POST /oauth-clients | 10 | 60s | Credential minting |
| GET /health GET /status | None | n/a | Monitoring probes |
| All other endpoints | 120 | 60s | Default |
Some other endpoints, such as single sign-on, e-signature links, kiosk devices and the SCIM routes, carry their own limits; treat any 429 as authoritative. Every response that is not throttled carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets), except from /health and /status, which are not limited. A throttled response is HTTP 429 Too Many Requests with a Retry-After header giving the seconds to wait; it does not carry the X-RateLimit-* headers. Browser code on another origin cannot read these headers, because cross-origin response headers are not exposed.
Limits are compiled into the service and are the same for every tenant and every key: there is no per-tenant or per-key raise. If a legitimate integration needs more headroom, email support@frontelio.com and describe the traffic. A change to a limit is a change to the service for everyone, so we treat it as a product request rather than a per-account setting.
Error shape
The HTTP status line is the one signal that is always present. Most API errors are JSON with statusCode and message, and usually error (the reason phrase). Validation failures return message as an array with one string per problem:
{
"message": [
"limit must be a number string"
],
"error": "Bad Request",
"statusCode": 400
}error is missing from three common responses. A missing, malformed or expired token, or a credential the route does not accept, returns a bare 401:
{
"message": "Unauthorized",
"statusCode": 401
}A throttled request returns:
{
"statusCode": 429,
"message": "ThrottlerException: Too Many Requests"
}An unhandled server error returns:
{
"statusCode": 500,
"message": "Internal server error"
}Some endpoints return a machine-readable value in code or error and may omit statusCode. Clock-in refusals return a code such as POLICY_ACK_REQUIRED, a shift that overlaps another returns 409 with error: "SHIFT_OVERLAP", and a module that is not in the tenant's plan returns 403 with error: "ModuleNotEntitled". The token exchange at POST /oauth/token uses the OAuth fields error and error_description, and /scim/v2 uses the SCIM error envelope (schemas, detail, status) defined by RFC 7644. Branch on the HTTP status first, then on code or error when one is present. Common statuses:
400— your request body or query is malformed.messagetells you which field. Request bodies and queries that are validated reject unknown properties rather than ignoring them.401— no credential, an invalid or expired token, or a credential this route does not accept. For a user token, refresh it and retry.403— authenticated but not permitted: the user's role or permissions do not cover the action, or the tenant's plan does not include the feature. Developer keys also get403when they lack the scope the route requires.404— the resource doesn't exist in your tenant. Looking up an id that belongs to another tenant returns 404, not 403, so ids cannot be enumerated across tenants.409— the write conflicts with existing data, for example a duplicate email or mobile number for a user, a duplicate outlet code within a tenant, or an overlapping shift.429— rate-limited (see above).500— server error. We've logged it; if you can reproduce it, email us the value of theX-Request-Idresponse header. Every response the API generates carries that header, and a request that sends its ownX-Request-Id(up to 80 characters) gets the same value back.
Pagination
Pagination is not uniform across the API. There is no page parameter and no meta block anywhere. The two styles most integrations meet both use limit and offset:
GET /userstakeslimit(default 50, minimum 1, maximum 500) andoffset(default 0). It returns a bare JSON array with no total, and it silently ignores query parameters it does not know, so?page=2returns the first page again. Page by increasingoffsetand stop when a response has fewer rows thanlimit.- The developer API list endpoints under
/publictakelimit(default 50, minimum 1, maximum 200) andoffset, and return adataarray with apaginationobject. There is no total:countis the number of rows in this response. Values outside the range are clamped, a non-numeric value is a400, and so is any query parameter the endpoint does not declare, includingpage.
{
"data": [ ... up to 50 employee rows ... ],
"pagination": {
"limit": 50,
"offset": 100,
"count": 37
}
}Other endpoints page differently or not at all. SCIM uses startIndex and count as RFC 7644 defines, and many list endpoints, such as GET /outlets, return every matching row in one array. Each reference page states its own behaviour; do not assume the envelope of one endpoint applies to another.
Explore the endpoints
Signup, login, refresh, invite-code redeem. Tokens, expiry, refresh interceptor pattern.
Full /access/* reference: zones, grants, visitors, API keys, verify, audit, integrations.
Subscribe to Frontelio events: the full event catalog, the envelope shape, and HMAC-SHA256 signature verification.
Connect Okta or Microsoft Entra (Azure AD) for automatic user provisioning: base URL, token setup, and the group-mapping model.