Browse docs
API Reference

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:

Base URL
https://api.frontelio.com/api/v1

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

CredentialFormatUse it forAccepted on
Bearer JWTSigned token returned by POST /auth/loginThe web and mobile apps, and scripts that act as a signed-in userEvery route except the ones described below
Developer API keyfco_live_ followed by 43 charactersServer-to-server integrations (Enterprise plan)The /public developer endpoints and /scim/v2
OAuth 2.0 access tokenfco_oauth_ followed by 43 characters, valid for 1 hourThe same routes as a developer key, for tools that use the client-credentials flowThe /public developer endpoints and /scim/v2
Reader-bridge keymk_ followed by 32 charactersDoor-reader hardwarePOST /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.

POST /auth/login
{
  "identifier": "owner@cafejoud.ae",
  "password": "Winter-Roast-2026"
}
200 OK
{
  "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.

POST /api-keys
{
  "name": "Back-office sync",
  "scopes": ["outlets:read", "employees:read", "shifts:read"],
  "expiresAt": "2027-03-01T00:00:00Z"
}
201 Created
{
  "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
curl "https://api.frontelio.com/api/v1/public/outlets?limit=2" \
  -H "Authorization: Bearer fco_live_<your key>"
200 OK
{
  "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:read
  • shifts:read, shifts:write
  • attendance:read, attendance:write
  • leave:read, leave:write
  • webhooks:read, webhooks:write
  • scim: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:

POST /oauth/token
{
  "grant_type": "client_credentials",
  "client_id": "fco_client_<your client id>",
  "client_secret": "fco_secret_<your client secret>"
}
200 OK
{
  "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:

401 Unauthorized
{
  "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.

MethodPathAuthDescription
GET/public/outletsAny valid key or tokenList the tenant's outlets.
GET/public/employeesAny valid key or tokenList employees (id, full name, email, status). Supports order=newest.
GET/public/webhook-catalogAny valid key or tokenEvery webhook event with a sample payload.
GET/public/shiftsshifts:readList shifts. Filter by outletId, from and to.
POST/public/shiftsshifts:writeCreate one shift.
GET/public/attendanceattendance:readList attendance events. Filter by outletId, userId, status, from and to.
PATCH/public/attendance/:idattendance:writeApprove or reject an attendance event. Clock-in and clock-out are not available here.
GET/public/leave-requestsleave:readList leave requests. Filter by status, type, userId, from and to.
POST/public/leave-requestsleave:writeCreate one leave request. The body must name the person in targetUserId.
GET/public/webhookswebhooks:readList webhook subscriptions. Secrets are masked.
POST/public/webhookswebhooks:writeCreate a subscription. The signing secret is returned once.
PATCH/public/webhooks/:idwebhooks:writeChange url, events or active.
DELETE/public/webhooks/:idwebhooks:writeRevoke a subscription.
POST/public/webhooks/:id/rotatewebhooks:writeIssue 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.

PurposeRoutesHow the caller is identified
Sign-in and accountPOST /auth/login
POST /auth/refresh
POST /auth/logout
POST /auth/forgot-password
POST /auth/reset-password
POST /auth/signup
POST /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 exchangePOST /oauth/tokenClient id and client secret in the body
Single sign-on (browser redirects)GET /auth/sso/lookup
POST /auth/sso/start
GET /auth/sso/callback
POST /auth/sso/saml/acs
GET /auth/microsoft/status
GET /auth/microsoft/start
GET /auth/microsoft/callback
Not identified: these start or complete a browser sign-in with your identity provider
Door accessPOST /access/visitors/:inviteCode/redeem
POST /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 devicesGET /kiosk/workers
POST /kiosk/clock
A kiosk device token as a Bearer credential
Links sent to people outside your teamGET /esign/public/:token
GET /esign/public/:token/download
POST /esign/public/:token/sign
GET /careers/:tenantSlug
GET /careers/:tenantSlug/jobs/:jobSlug
POST /careers/:tenantSlug/jobs/:jobSlug/apply
POST /careers/:tenantSlug/jobs/:jobSlug/apply/resume
GET /u/:uid
A signing token, or a public careers page or profile identifier, in the path
Provider callbacksGET /whatsapp/webhook
POST /whatsapp/webhook
GET /integrations/xero/callback
Called by WhatsApp and Xero: a verify token or signature, or the OAuth state value
Public dataPOST /public/leads
GET /branding/public
GET /branding/logo/:tenantId
GET /app/config
GET /health
GET /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:

EndpointLimitWindowWhy
POST /auth/login3060sBrute-force defence
POST /auth/refresh3060sReplay protection
POST /auth/signup560sAnti-spam new-tenant signups
POST /auth/forgot-password560sMail-bombing and enumeration defence
POST /auth/reset-password1060sReset-token guessing defence
POST /auth/invite/:code/redeem1015 minInvite-code guessing defence
POST /access/visitors/:inviteCode/redeem55 minMagic-link guessing defence
POST /oauth/token3060sClient-secret guessing defence
GET /public/*6060sDeveloper API reads
POST, PATCH, DELETE /public/*1060sDeveloper API writes
POST /users/bulk-import
POST /outlets/bulk-import
POST /vendors/bulk-import
POST /assets/bulk-import
POST /asset-categories/bulk-import
660sOne call can write up to 500 rows
POST /api-keys
POST /oauth-clients
1060sCredential minting
GET /health
GET /status
Nonen/aMonitoring probes
All other endpoints12060sDefault

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:

HTTP 400 — GET /public/outlets?limit=abc
{
  "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:

HTTP 401
{
  "message": "Unauthorized",
  "statusCode": 401
}

A throttled request returns:

HTTP 429
{
  "statusCode": 429,
  "message": "ThrottlerException: Too Many Requests"
}

An unhandled server error returns:

HTTP 500
{
  "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. message tells 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 get 403 when 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 the X-Request-Id response header. Every response the API generates carries that header, and a request that sends its own X-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 /users takes limit (default 50, minimum 1, maximum 500) and offset (default 0). It returns a bare JSON array with no total, and it silently ignores query parameters it does not know, so ?page=2 returns the first page again. Page by increasing offset and stop when a response has fewer rows than limit.
  • The developer API list endpoints under /public take limit (default 50, minimum 1, maximum 200) and offset, and return a data array with a pagination object. There is no total: count is the number of rows in this response. Values outside the range are clamped, a non-numeric value is a 400, and so is any query parameter the endpoint does not declare, including page.
GET /public/employees?limit=50&offset=100
{
  "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