Browse docs
API · /auth

Authentication endpoints

Sign up, log in, refresh and revoke tokens, redeem invite codes, manage passwords, and switch between companies.

All authentication endpoints live under /auth on the API base URL https://api.frontelio.com/api/v1. The endpoints that hand out tokens (login, refresh, signup, invite redemption) and the password-recovery endpoints are public: they do not need an existing token, because they are how a client gets one. Everything that acts on the signed-in user needs an Authorization: Bearer <accessToken> header. Send JSON with Content-Type: application/json.

Requests are counted per user when they carry a valid access token and per IP address otherwise, so the limits on public endpoints (which are called without a token) are per IP. Routes without a limit of their own use the platform default of 120 requests per minute, counted separately for each route. See Rate limiting for what a throttled request looks like.

MethodPathAuthDescription
POST/auth/loginPublic · 30/min/IPExchange an email or mobile number and a password for a token pair.
POST/auth/refreshPublic · 30/min/IPTrade a refresh token for a new token pair. The refresh token is rotated.
POST/auth/logoutPublic · 30/min/IPRevoke the session behind a refresh token. Answers 200 even for an invalid token.
POST/auth/signupPublic · 5/min/IPCreate a new company and its owner, and sign the owner in. Optional invite code for sibling-company signups.
POST/auth/invite/:code/redeemPublic · 10/15min/IPRedeem a staff invite code for a token pair. Takes no request body.
POST/auth/forgot-passwordPublic · 5/min/IPEmail a single-use password-reset link.
POST/auth/reset-passwordPublic · 10/min/IPSet a new password using a reset token.
GET/auth/meBearer JWTThe signed-in user's profile, roles, permissions, company and working context.
PATCH/auth/me/passwordBearer JWT · 10/min/userChange your password. Requires the current password and ends every session.
POST/auth/me/set-initial-passwordBearer JWT · 10/min/userSet a first password on an account that has none (invited workers).
GET/auth/membershipsBearer JWTList the companies you can switch into: your own, plus linked accounts in the same company group.
POST/auth/switch-tenantBearer JWTIssue a fresh token pair for another company of your company group where you have a linked account.

Company sign-in with Microsoft and SAML/OIDC single sign-on has its own browser-redirect routes; see Single sign-on.

Tokens and lifetimes

Login, refresh, signup, invite redemption and tenant switching all return the same five token fields (signup, redemption and switching add a few more, shown in their sections):

  • accessToken — a signed JWT. Send it as Authorization: Bearer <accessToken> on every authenticated request.
  • refreshToken — a signed JWT used only with POST /auth/refresh and POST /auth/logout. Treat it as an opaque string and keep it in secure storage (Keychain on iOS, Keystore on Android, an HttpOnly cookie or IndexedDB on web).
  • accessExpiresIn and refreshExpiresIn — the lifetimes the server applied, as duration strings such as 15m and 7d.
  • tokenType — always Bearer.

By default the accessToken is valid for 15 minutes and the refreshToken for 7 days. The lifetimes are deployment settings (JWT_ACCESS_TTL and JWT_REFRESH_TTL), so read accessExpiresIn and refreshExpiresIn from the response instead of hard-coding them. Every refresh issues a new refresh token with a fresh lifetime, so an active client stays signed in, but a client that has been idle for longer than the refresh lifetime must sign in again.

Neither login nor refresh returns a user object. The identity is in the access token's payload (below) and in GET /auth/me. The payload's roles are a snapshot from when the token was issued: use them for display and navigation only, because the API re-checks roles and permissions on every request.

Access token payload
{
  "sub": "3f0c9c2e-6a41-4d77-9b0e-2f5d8a1c7e94",   // user id
  "tenantId": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
  "email": "owner@cafejoud.ae",
  "fullName": "Mariam Al-Hashimi",
  "roles": ["COMPANY_ADMIN", "TENANT_OWNER"],
  "type": "access",
  "iat": 1790000000,
  "exp": 1790000900
}

POST /auth/login

Exchange a user identifier and password for a token pair. identifier is the user's email address (matched case-insensitively) or the mobile number stored on the account, which is normally in international format such as +971501234567. The mobile match is exact: 0501234567 and 971501234567 do not find that account. Leading and trailing spaces are trimmed, and the rest of the identifier is matched literally: % and _ are ordinary characters, never wildcards. password must be at least 6 characters here (the stricter policy applies wherever a password is set, see Passwords). Any other property in the body is rejected with 400.

The same email address or mobile number can belong to accounts in several organisations. An organisation is a company group, or a single company that is in no group, so the sibling companies of one group count as one. Login does not simply take the newest account. It tests the password against one account of each organisation, normally its most recently created one, newest first, and signs in to the first account whose password matches. Within a company group, the password of the group's most recently created account is therefore the one that counts. Only the 8 most recently created accounts that share the identifier are considered; when that many exist, the accounts that platform operators have linked across companies are always included as well and are tried first. Use POST /auth/switch-tenant to reach the other companies of a group (see Multi-tenant tokens).

Request

POST /auth/login
{
  "identifier": "owner@cafejoud.ae",
  "password": "Sunrise-Cafe-2026"
}

Response · 200 OK

POST /auth/login · 200
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "accessExpiresIn": "15m",
  "refreshExpiresIn": "7d",
  "tokenType": "Bearer"
}

Errors

  • 400 — identifier missing or empty, password shorter than 6 characters, a non-string value, or an unknown property.
  • 401 — That email/mobile or password doesn't match. Try again, or use Forgot password. The same message covers an unknown identifier, a wrong password and a deactivated account, so the response never reveals which accounts exist. If the account's company is deleted, suspended or cancelled the message says so and points to support@frontelio.com; when several accounts were tested, the account is the one whose password matched, or the newest one when none did.
  • 429 — more than 30 attempts per minute from one IP.

POST /auth/refresh

When the access token expires (a 401 from any authenticated endpoint), exchange the refresh token for a new pair. Refresh tokens are rotated: every successful refresh returns a new refresh token in the same session and marks the one you sent as used. Always store the newest refresh token.

Request

POST /auth/refresh
{
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

Response · 200 OK

POST /auth/refresh · 200
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs... (new)",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs... (new)",
  "accessExpiresIn": "15m",
  "refreshExpiresIn": "7d",
  "tokenType": "Bearer"
}

Reuse detection

A refresh token that has already been used must not be sent again. If one is presented, the API treats it as a possible stolen token:

  • Within 30 seconds of the rotation, and only while the newer token has not been used yet, the request is treated as a retry after a lost response. It succeeds once and returns a fresh pair.
  • Otherwise the whole session is revoked and the request fails with 401 Session has been revoked. Every token in that session, including the newest, stops working and the user must sign in again.

Send one refresh at a time per session. Two overlapping refreshes with the same token can fail the second with 401 Refresh already in progress. and, in a narrow window, can end the session. Treat any 401 from /auth/refresh as the end of the session: discard the tokens and sign in again. A refresh also fails with 401 when the user was deactivated, the company was suspended, or the password was changed or reset after the token was issued.

Errors

  • 400 — refreshToken missing, empty or not a string, or an unknown property.
  • 401 — Invalid or expired refresh token. for a token that is malformed, forged or past its lifetime.
  • 429 — more than 30 requests per minute from one IP.

POST /auth/logout

Explicit sign-out. Send the refresh token and the API revokes that token's whole session, so a captured refresh token cannot outlive the sign-out. refreshToken is optional. The endpoint is public and answers 200 with { "ok": true } even for an expired, malformed or already-revoked token, because signing out must never fail. Only a body that is not valid (a non-string refreshToken or an unknown property) is rejected with 400.

Logout revokes refresh tokens only. An access token you already hold keeps working until it expires (15 minutes by default), so discard it on the client as well.

POST /auth/logout
{
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
POST /auth/logout · 200
{
  "ok": true
}

POST /auth/signup

Create a new company (tenant) and its first owner, then sign the owner in. There are two paths through the same endpoint, distinguished by whether the body has a non-empty code:

  • Self-service signup (no code) — anyone can start a brand-new company from zero. Provisions the tenant, the owner (roles TENANT_OWNER and COMPANY_ADMIN) and the full role and permission matrix, on the FREE plan. A default outlet is created only when seedDemoData is true; otherwise the company starts with no outlet and the owner creates one before calling outlet-scoped endpoints.
  • Invite signup (code present) — consume an FCO-INV-XXXX-XXXX code that a group owner minted. The code can pre-parent the new tenant under an existing TenantGroup, which is how you add a sibling company to a group you already own. This path always creates a default outlet and ignores seedDemoData, country, timezone and requestedPlan.

Either way the response signs the new owner in immediately. Throttled at 5 requests per minute per IP.

Request

  • tenantName — required, 1 to 120 characters.
  • ownerEmail — required, a valid email address. It becomes the owner's login identifier.
  • ownerFullName — required.
  • password — required, at least 12 characters with at least one letter and one digit.
  • acceptedTerms — required, must be the boolean true (agreement to the Terms of Service and Privacy Policy). The string "true" is rejected.
  • code — optional invite code (invite signup).
  • seedDemoData — optional boolean, default false, self-service only. When true, also creates a default outlet, five demo staff accounts, sample shifts and content, and returns demoCredentials.
  • country — optional, self-service only. ISO 3166-1 alpha-2 code, default AE. It is stored as the company's country and, unless you send timezone, sets the default timezone.
  • timezone — optional, self-service only. An IANA timezone id such as Asia/Dubai. When omitted it is derived from country, and a country with no built-in mapping gets Asia/Dubai, so send timezone explicitly for such a country. An invalid id is a 400.
  • requestedPlan — optional, self-service only. One of FREE, BASIC, STARTER, GROWTH, ENTERPRISE. This is recorded as the plan the visitor asked for and grants nothing: every new company starts on FREE. An unrecognised value is ignored.
  • solutionProfile — optional, both paths. One of timeAttendance, hrPeople, frontlineOps, everything (default). Sets which features the company starts with visible. An unknown value is a 400.
POST /auth/signup
{
  "tenantName": "Café Joud",
  "ownerEmail": "owner@cafejoud.ae",
  "ownerFullName": "Mariam Al-Hashimi",
  "password": "Sunrise-Cafe-2026",
  "acceptedTerms": true,
  "seedDemoData": true,               // optional, default false
  "country": "AE",                    // optional, default AE
  "timezone": "Asia/Dubai",           // optional, derived from country
  "requestedPlan": "GROWTH",          // optional, recorded as intent only
  "solutionProfile": "everything"     // optional, default everything
}

To join a sibling company to a group, add the invite code and leave out the self-service-only fields:

POST /auth/signup (invite code)
{
  "code": "FCO-INV-7KQ4-M9PX",
  "tenantName": "Café Joud — Marina",
  "ownerEmail": "owner@cafejoud.ae",
  "ownerFullName": "Mariam Al-Hashimi",
  "password": "Sunrise-Cafe-2026",
  "acceptedTerms": true
}

Response · 200 OK

The five token fields, plus tenant, owner and demoCredentials. There is no user key. demoCredentials is always present: null unless demo data was seeded, otherwise a single object with the shared demo password (not a list of accounts). The five demo staff are Sarah Mansoor, Hassan Ali, Maria Santos, Ahmed Khan and Priya Sharma; their generated email addresses appear in the company's Team list.

POST /auth/signup · 200
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "accessExpiresIn": "15m",
  "refreshExpiresIn": "7d",
  "tokenType": "Bearer",
  "tenant": {
    "id": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
    "name": "Café Joud",
    "legalName": "Café Joud",
    "industry": "Food & Beverage",
    "country": "AE",
    "timezone": "Asia/Dubai",
    "plan": "FREE",
    "subscriptionPlan": "FREE",
    "onboardingComplete": false
  },
  "owner": {
    "id": "3f0c9c2e-6a41-4d77-9b0e-2f5d8a1c7e94",
    "email": "owner@cafejoud.ae",
    "mobile": null,
    "fullName": "Mariam Al-Hashimi"
  },
  "demoCredentials": {
    "password": "Demo2026!!",
    "note": "Use this password to sign in as any of the 5 pre-seeded demo staff (Sarah, Hassan, Maria, Ahmed, Priya). Their emails appear in your Team list."
  }
}

On the invite path tenant carries only id, name, legalName, industry, country and timezone, and demoCredentials is null.

POST /auth/signup · 200 (invite code)
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "accessExpiresIn": "15m",
  "refreshExpiresIn": "7d",
  "tokenType": "Bearer",
  "tenant": {
    "id": "8a1d5e3c-92b7-4c60-a4f8-1e7b3d9c5a26",
    "name": "Café Joud — Marina",
    "legalName": "Café Joud — Marina",
    "industry": "Food & Beverage",
    "country": "UAE",
    "timezone": "Asia/Dubai"
  },
  "owner": {
    "id": "5b2e8d1a-7c34-4f90-b6a1-3d9e0c4f8a72",
    "email": "owner@cafejoud.ae",
    "mobile": null,
    "fullName": "Mariam Al-Hashimi"
  },
  "demoCredentials": null
}

Errors

The body is validated by the service and answered with 400, checked in this order:

  • Company name must be 1-120 characters.
  • A valid owner email is required.
  • Owner full name is required.
  • Password must be at least 12 characters.
  • Password must include at least one letter and one number.
  • You must agree to the Terms of Service and Privacy Policy to create an account.

On the invite path the first message reads Tenant name must be 1-120 characters. and a bad code is answered with 404 Invite code not recognised. or 403 (already used, or expired). A 409 means the same owner email already owns a company with that name. Fields the endpoint does not read are ignored rather than rejected, so send only the fields listed above.

POST /auth/invite/:code/redeem

Magic-link sign-in for a staff member whom an owner or manager has already created. An invite code is minted for an existing user, either in the web app (Users, Invite staff) or with POST /user-invites and POST /user-invites/bulk (permission user.invite, body userId or userIds, optional expiresAt). The worker then redeems the code, typically in the mobile app or by scanning the QR code.

Redeeming takes no request body. The code already identifies the account, so redemption does not create a user or set a password, and any body you send is ignored. It marks the code used and signs that user in.

Codes are case-insensitive opaque strings that start with FCO- followed by 10 upper-case letters and digits, for example FCO-4CE7URJ588. (Codes that hiring conversions issued before September 2026 were six characters long; they still redeem.) A code is valid for 90 days by default; the two /user-invites endpoints accept an expiresAt to shorten or extend that.

bash
curl -sX POST https://api.frontelio.com/api/v1/auth/invite/FCO-4CE7URJ588/redeem

Response · 201 Created

The status is 201 Created. The body is the five token fields, a thin user block (this is not the same shape as login), and promptPasswordSetup.

POST /auth/invite/:code/redeem · 201
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "accessExpiresIn": "15m",
  "refreshExpiresIn": "7d",
  "tokenType": "Bearer",
  "user": {
    "id": "5b2e8d1a-7c34-4f90-b6a1-3d9e0c4f8a72",
    "email": "layla.hassan@cafejoud.ae",
    "fullName": "Layla Hassan"
  },
  "promptPasswordSetup": true
}

promptPasswordSetup is true when the account has no password yet, which is the case for a worker who was created without one. Prompt them to set one straight away with POST /auth/me/set-initial-password, so they can sign back in with a password once the refresh token issued by the invite lapses.

Errors

  • 400 — the code is blank or shorter than 4 characters.
  • 404 — unknown code: That invite code is not valid.
  • 403 — the code was revoked or has expired, or the account is not active.
  • 409 — the code was already used: That invite has already been used. Sign in with your email + password instead.
  • 429 — more than 10 attempts per 15 minutes from one IP.

Passwords

Every endpoint that sets a password (signup, reset, change and set-initial-password) enforces the same policy: at least 12 characters, with at least one letter (A-Z or a-z) and at least one digit (0-9). Reset, change and set-initial-password also cap the length at 200 characters, and reject a weak password with 400 and the messages newPassword must be at least 12 characters., newPassword must contain at least one letter. or newPassword must contain at least one digit.

POST /auth/forgot-password

Request a reset link. identifier is the email or mobile number the user signs in with, matched literally (% and _ are ordinary characters, never wildcards). The response is always 200 with { "ok": true }, whether or not the identifier matched an account and however many it matched, so it cannot be used to find out who has one. The email goes only to the address on file for the account; an account with no email on file gets none.

The same identifier can belong to accounts in several organisations (a company group, or a single company that is in no group). A link is then sent for one active account of each organisation, its most recently created one, newest first, and for at most 3 organisations. The companies of one group share a single email. When more than one email is sent, each names the company it is for, and a link resets only the account it was issued for. The link is valid for 30 minutes and works once, and requesting a new link cancels any earlier unused one for the same account.

POST /auth/forgot-password
{
  "identifier": "owner@cafejoud.ae"
}
POST /auth/forgot-password · 200
{
  "ok": true
}

POST /auth/reset-password

Set a new password with the token from the emailed link. On success the response is 200 with { "ok": true } and every session of that user is ended: all access and refresh tokens issued before the reset are rejected, so the user signs in again with the new password. An invalid, expired or already-used token is a 400 This reset link is invalid or has expired. Please request a new one.

POST /auth/reset-password
{
  "token": "kR3vY0qLw8mZ2xN5aT7uBcD9eF1gH4jK6pS8rV0yQ2w",
  "newPassword": "Sunrise-Cafe-2027"
}
POST /auth/reset-password · 200
{
  "ok": true
}

PATCH /auth/me/password

Change the signed-in user's password. It requires the current password so a stolen access token alone cannot lock the owner out. A wrong current password is a 401 Current password is incorrect. and a new password equal to the current one is a 401 New password must be different from the current one. On success the response is 200 with { "ok": true }. Every token issued to the user before the change stops working, including the caller's own, so the client must sign in again with the new password.

PATCH /auth/me/password
{
  "currentPassword": "Sunrise-Cafe-2026",
  "newPassword": "Sunrise-Cafe-2027"
}
PATCH /auth/me/password · 200
{
  "ok": true
}

POST /auth/me/set-initial-password

Set a first password on an account that has none, such as a worker who signed in through an invite code (see promptPasswordSetup). It refuses an account that already has a password with 401 You already have a password. Use Change password instead. so it can never be used to skip the current-password check. On success the response is 200 with { "ok": true } and existing sessions stay signed in.

POST /auth/me/set-initial-password
{
  "newPassword": "Sunrise-Cafe-2026"
}
POST /auth/me/set-initial-password · 200
{
  "ok": true
}

GET /auth/me

Returns the current user's profile, roles, resolved permission set, company and working context. Useful as a session-validity probe and to render role-aware UI. There is no top-level primaryOutletId and the response does not include the company's plan: the user's primary outlet is employeeProfile.primaryOutletId, and employeeProfile is null for users without an employee profile.

GET /auth/me
Authorization: Bearer <accessToken>
GET /auth/me · 200
{
  "id": "3f0c9c2e-6a41-4d77-9b0e-2f5d8a1c7e94",
  "tenantId": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
  "email": "owner@cafejoud.ae",
  "mobile": null,
  "fullName": "Mariam Al-Hashimi",
  "groupUid": "1-100-01-01-0042",
  "userType": "EMPLOYEE",
  "roles": ["COMPANY_ADMIN", "TENANT_OWNER"],
  "permissions": ["tenant.manage", "user.invite"],
  "employeeProfile": {
    "employeeCode": "ADM-001",
    "jobTitle": "Company Admin",
    "primaryOutletId": null
  },
  "whatsappOptIn": false,
  "tenant": {
    "id": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
    "name": "Café Joud",
    "brandShortName": null,
    "brandPrimaryColor": null,
    "brandLogoUrl": null,
    "timezone": "Asia/Dubai",
    "onboardingComplete": false
  },
  "workingContext": {
    "source": "affiliation",
    "workingTenant": {
      "id": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
      "name": "Café Joud",
      "brandShortName": null,
      "brandPrimaryColor": null,
      "brandLogoUrl": null,
      "timezone": "Asia/Dubai"
    },
    "workingOutletId": null,
    "role": "Company Admin",
    "isSeconded": false
  }
}

permissions is truncated above; the real list holds every permission the user's roles grant. userType is one of EMPLOYEE, CUSTOMER, SUPPLIER or SERVICE_ACCOUNT. workingContext.source is affiliation, legacy-outlet or sponsor.

Multi-tenant tokens

A person can have an account in several companies of one company group (a TenantGroup), for example the owner of a group of sibling companies. Each account is its own user with its own roles, and each access token is scoped to one company only. Two accounts count as the same person only when both carry the same linkedIdentityKey (by convention the lower-cased email address) and their companies belong to the same company group. An email address alone never links accounts, because signing up does not verify who owns an email address. Platform operators set up the link, and only between companies of one group; it is not something a company admin can create through the API.

An account with no linkedIdentityKey, or whose company is in no group, can reach only its own company: GET /auth/memberships lists just that company and POST /auth/switch-tenant refuses every other one.

GET /auth/memberships

Lists the caller's own account plus every other active account whose linkedIdentityKey equals the caller's (compared case-insensitively), in a company of the caller's company group that is not deleted or cancelled. Both conditions are required: the caller needs a linkedIdentityKey of their own and a company that is in a group. Otherwise only the caller's own company is listed. An account that only shares the email address is never listed, and neither is one in a company outside the group, whatever its key. A suspended company is listed but cannot be switched into. Items are ordered by company name, so the current company is not necessarily first; isCurrent marks the company the token is scoped to.

GET /auth/memberships · 200
{
  "items": [
    {
      "tenantId": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
      "tenantName": "Café Joud",
      "brandShortName": null,
      "brandPrimaryColor": null,
      "brandLogoUrl": null,
      "userId": "3f0c9c2e-6a41-4d77-9b0e-2f5d8a1c7e94",
      "fullName": "Mariam Al-Hashimi",
      "roles": ["COMPANY_ADMIN", "TENANT_OWNER"],
      "isCurrent": true
    },
    {
      "tenantId": "8a1d5e3c-92b7-4c60-a4f8-1e7b3d9c5a26",
      "tenantName": "Café Joud — Marina",
      "brandShortName": null,
      "brandPrimaryColor": null,
      "brandLogoUrl": null,
      "userId": "5b2e8d1a-7c34-4f90-b6a1-3d9e0c4f8a72",
      "fullName": "Mariam Al-Hashimi",
      "roles": ["TENANT_OWNER"],
      "isCurrent": false
    }
  ]
}

POST /auth/switch-tenant

Issue a new token pair scoped to a different company. It needs only a valid access token: there is no password prompt. Send tenantId as a non-empty string; a missing, empty or non-string value (or an unknown property) is a 400. The caller's own account must carry a linkedIdentityKey. The target company must be in the caller's company group and contain an active account with the same linkedIdentityKey (compared case-insensitively), and the company must not be deleted, cancelled or suspended. An account that only shares the email address is not enough. Otherwise the answer is 401 No linked account on that tenant, or that company account is not active. Ask an admin. A caller whose account has no linkedIdentityKey gets 401 Your account is not linked across tenants. Ask an admin to set linkedIdentityKey. Sending the tenantId of the company the token is already scoped to only refreshes the session and needs no company group; the other conditions still apply.

POST /auth/switch-tenant
{
  "tenantId": "8a1d5e3c-92b7-4c60-a4f8-1e7b3d9c5a26"
}

The response is the five token fields plus a switchedTo confirmation. The new refresh token belongs to the target company.

POST /auth/switch-tenant · 200
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "accessExpiresIn": "15m",
  "refreshExpiresIn": "7d",
  "tokenType": "Bearer",
  "switchedTo": {
    "tenantId": "8a1d5e3c-92b7-4c60-a4f8-1e7b3d9c5a26",
    "userId": "5b2e8d1a-7c34-4f90-b6a1-3d9e0c4f8a72",
    "fullName": "Mariam Al-Hashimi"
  }
}

Single sign-on

These routes power the Sign in with Microsoft and company SSO buttons on the Frontelio sign-in screen. They are browser redirect flows rather than JSON APIs, and are all public. Most integrations should use password sign-in and refresh instead.

Sign in with Microsoft only signs in an existing user; it never creates one. The verified Microsoft email address is matched to a Frontelio account case-insensitively and literally (% and _ are not wildcards). When the address belongs to accounts in several companies, an account that platform operators have linked across companies is preferred, and otherwise the most recently created account is used.

MethodPathAuthDescription
GET/auth/microsoft/statusPublicReturns enabled: true or false, whether Microsoft sign-in is configured on this deployment.
GET/auth/microsoft/startPublic · 20/min/IPRedirects (302) to Microsoft to begin sign-in.
GET/auth/microsoft/callbackPublic · 20/min/IPMicrosoft's redirect target. Completes sign-in and redirects (302) to the web app's /login with the token pair in the URL fragment.
GET/auth/sso/lookupPublic · 20/min/IPQuery ?email=. Returns null when the email's domain has no company SSO, or when no active owner or admin account of the company that configured it sits on that domain, otherwise tenantId, domain, provider and protocol.
POST/auth/sso/startPublic · 20/min/IPBody domain and optional redirectAfter (a relative path starting with a single /). Returns authorizeUrl, state and protocol (OIDC or SAML); 404 when the domain has no SSO; 400 when domain is not a hostname or redirectAfter is not a relative path.
GET/auth/sso/callbackPublic · 20/min/IPOIDC redirect target (query code and state). Returns accessToken, refreshToken, a user block and redirectAfter.
POST/auth/sso/saml/acsPublic · 20/min/IPSAML assertion consumer service (form-encoded SAMLResponse and RelayState). Returns the same body as the OIDC callback.

Rate limiting

Limits are listed in the tables above. A request with a valid access token is limited per user, so staff sharing one office network do not share a budget; every other request is limited per IP address. When a limit is exceeded the API answers 429 Too Many Requests with a Retry-After header (in seconds) and blocks that caller for the full window: 60 seconds for the per-minute limits, 15 minutes for invite redemption. Wait for Retry-After before retrying.

Quick curl example

bash
BASE=https://api.frontelio.com/api/v1

# 1. Log in and keep the whole token response
LOGIN=$(curl -sX POST "$BASE/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"identifier":"owner@cafejoud.ae","password":"Sunrise-Cafe-2026"}')
ACCESS=$(echo "$LOGIN" | jq -r .accessToken)
REFRESH=$(echo "$LOGIN" | jq -r .refreshToken)

# 2. Verify the token works
curl -sX GET "$BASE/auth/me" \
  -H "Authorization: Bearer $ACCESS"

# 3. Refresh when the access token expires, then store the NEW refreshToken
curl -sX POST "$BASE/auth/refresh" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg t "$REFRESH" '{refreshToken: $t}')"

Continue to the /access API reference for the Frontelio Access endpoints.