Frontelio Access endpoints
Full REST reference for everything under /access — zones, grants, visitors, credential minting, the verify hot path, audit, and reader integrations.
The /access/* endpoints power phone-as-credential access control. Most are JWT-authenticated and role-gated; the hot-path /access/verify and /access/reader-heartbeat use per-tenant API keys so reader bridges can authenticate without needing user JWTs.
Access endpoints are plan-gated by the accessControl billing feature flag — included free on GROWTH and above. On FREE / BASIC plans these endpoints return 402 Payment Required with a link to upgrade.
Endpoint overview
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /access/zones | Bearer JWT | List Zones in this tenant (optionally filter by outletId). |
| GET | /access/zones/:id | Bearer JWT | Get a single Zone with reader bindings. |
| POST | /access/zones | Bearer JWT | Create a Zone. |
| PATCH | /access/zones/:id | Bearer JWT | Update a Zone. |
| DELETE | /access/zones/:id | Bearer JWT | Delete a Zone (cascade-revokes any active grants). |
| GET | /access/grants | Bearer JWT | List Grants (filter by userId or zoneId). |
| GET | /access/users/:userId/grants | Bearer JWT | List all Grants held by a user. |
| POST | /access/grants | Bearer JWT | Create a Grant. |
| POST | /access/grants/bulk | Bearer JWT | Create multiple Grants in one call. |
| POST | /access/grants/:id/revoke | Bearer JWT | Revoke an active Grant. |
| DELETE | /access/grants/:id | Bearer JWT | Delete a Grant row (hard delete; use revoke for soft). |
| GET | /access/visitors | Bearer JWT | List active visitor passes. |
| POST | /access/visitors | Bearer JWT | Create a time-limited visitor pass. |
| DELETE | /access/visitors/:inviteCode | Bearer JWT | Revoke a visitor pass. |
| POST | /access/visitors/:inviteCode/redeem | Public · 5/5min/IP | Visitor self-redeem via magic link. |
| GET | /access/api-keys | Bearer JWT | List per-tenant API keys (secrets are NOT included). |
| POST | /access/api-keys | Bearer JWT | Mint a new API key. The secret is returned ONCE. |
| POST | /access/api-keys/:id/revoke | Bearer JWT | Revoke an API key. Cannot be undone. |
| POST | /access/credential | Bearer JWT (user) | Mint a 24h credential JWT for the current user. |
| POST | /access/verify | Bearer API key | HOT PATH. Reader presents a credential; we return GRANT or DENY. |
| POST | /access/reader-heartbeat | Bearer API key | Bridge heartbeat. Every 5 min so the admin UI shows online/offline. |
| GET | /access/readers | Bearer JWT | List observed readers with online status. |
| GET | /access/audit | Bearer JWT | Paginated audit log (every verify, grant, revoke). |
| GET | /access/reports/who-where | Bearer JWT | Who-was-where report for a date range. |
| GET | /access/integrations | Bearer JWT | List external reader integrations (Kisi / Salto / Brivo / HID). |
| POST | /access/integrations | Bearer JWT | Connect a new external access provider. |
| PATCH | /access/integrations/:id | Bearer JWT | Update integration config. |
| DELETE | /access/integrations/:id | Bearer JWT | Remove an integration. |
| POST | /access/integrations/:id/test-connection | Bearer JWT | Validate credentials against the provider. |
| POST | /access/integrations/:id/sync-doors | Bearer JWT | Pull the latest door list from the provider. |
| GET | /access/integrations/:id/doors | Bearer JWT | List discovered external doors. |
| POST | /access/integrations/:id/doors/:doorId/map | Bearer JWT | Map an external door to a Frontelio Zone. |
| POST | /access/webhooks/:integrationId | Public · HMAC-verified | Inbound webhook receiver. The provider signs the body; we verify and ingest. |
Zones
A Zone is a physical space that can be unlocked. Authoring Zones is the first thing you'll do after enabling Access on a tenant. Most operations on Zones require one of these roles: TENANT_OWNER, COMPANY_ADMIN, HR_MANAGER, AREA_MANAGER. Read also allows OUTLET_MANAGER.
POST /access/zones
{
"name": "Back of House — Stockroom",
"outletId": "outlet_dxb1",
"readers": ["BRIDGE-dxb1-stockroom"],
"scheduleAware": true,
"description": "Dry-goods stockroom. Manager-only outside opening hours."
}{
"id": "zone_abc",
"name": "Back of House — Stockroom",
"outletId": "outlet_dxb1",
"readers": ["BRIDGE-dxb1-stockroom"],
"scheduleAware": true,
"createdAt": "2026-05-28T08:32:10.123Z"
}Grants
A Grant ties a User (or a Role) to a Zone. The schedule-aware flag on the Zone determines whether the grant is enforced only during the user's active shift window.
POST /access/grants
{
"userId": "user_abc",
"zoneId": "zone_xyz",
"startsAt": "2026-06-01T00:00:00Z", // optional
"endsAt": "2026-12-01T00:00:00Z", // optional — auto-expires
"note": "6-month contractor"
}POST /access/grants/bulk
Create grants for a list of users in one call. Common at onboarding:
{
"userIds": ["user_a", "user_b", "user_c"],
"zoneIds": ["zone_x", "zone_y"]
}POST /access/grants/:id/revoke
Revokes a grant immediately. The next /access/verify call referencing that user + zone returns DENY. Body is optional; you can provide a reason for the audit log.
Visitors
Time-limited passes for non-staff. Visitor grants are stored as AccessGrant rows with userId NULL and a shared unique inviteCode. The redeem endpoint is public — the secret is the code itself, and the route is rate-limited 5/IP per 5min to deter guessing (the 24-byte base64url codes already give 192 bits of entropy, but the throttle is cheap insurance).
POST /access/visitors
{
"displayName": "ACME Pest Control — Ahmed",
"zoneIds": ["zone_kitchen", "zone_storage"],
"validFrom": "2026-06-01T08:00:00Z",
"validUntil": "2026-06-01T11:00:00Z",
"phone": "+971501234567", // for SMS magic link
"sendSms": true
}{
"inviteCode": "vis_a3F2X1bC...",
"redeemUrl": "https://app.frontelio.com/visitor/vis_a3F2X1bC...",
"qrCodeSvg": "<svg>...</svg>",
"validFrom": "2026-06-01T08:00:00Z",
"validUntil": "2026-06-01T11:00:00Z"
}POST /access/visitors/:inviteCode/redeem
Public endpoint. The visitor opens the magic link, the redeem endpoint mints a short-lived credential JWT that works only on the visitor's mapped zones, only within validFrom..validUntil.
API Keys
Per-tenant secrets that reader bridges and integrations use to authenticate to /access/verify and /access/reader-heartbeat. Format: mk_ + 32 random base64url bytes.
POST /access/api-keys
{
"displayName": "DXB-Marina — Door bridges",
"outletId": "outlet_dxb1" // optional — scopes the key
}{
"id": "ak_abc",
"displayName": "DXB-Marina — Door bridges",
"secret": "mk_5RHzN8qPmZbcD2EfQ7jX...", // <- COPY NOW
"createdAt": "2026-05-28T08:32:10.123Z"
}POST /access/credential
Authenticated as the current user (Bearer JWT). Mints a 24-hour credential JWT bound to that user. The mobile app calls this on every "My Access" open so each device always has a fresh credential.
Authorization: Bearer <userJwt>
(no body)
200 OK
{
"credentialId": "eyJhbGciOiJIUzI1NiIs... 24h JWT",
"expiresIn": "24h",
"expiresAt": "2026-05-29T08:32:10.123Z",
"issuedFor": {
"userId": "user_abc",
"tenantId": "tenant_xyz"
}
}POST /access/verify (HOT PATH)
The endpoint reader bridges call on every tap. API-key authenticated — the bridge presents the tenant's mk_* key, not a user JWT. Returns GRANT or DENY within ~80 ms p95.
Authorization: Bearer mk_REPLACE_WITH_YOUR_KEY
Content-Type: application/json
{
"credentialId": "eyJhbGciOiJIUzI1NiIs... (the JWT the phone broadcast)",
"readerId": "BRIDGE-dxb1-stockroom",
"source": "PHONE_NFC" // or PHONE_QR | VISITOR | CARD
}{
"decision": "GRANT",
"userId": "user_abc",
"userName": "Layla Hassan",
"zoneName": "Back of House — Stockroom",
"reason": null,
"replay": false
}{
"decision": "DENY",
"userId": "user_abc",
"userName": "Layla Hassan",
"zoneName": "Back of House — Stockroom",
"reason": "OUT_OF_SHIFT_WINDOW",
"replay": false
}reason is one of: NO_GRANT, GRANT_REVOKED, OUT_OF_SHIFT_WINDOW, OUT_OF_DATE_WINDOW, CREDENTIAL_EXPIRED, SIGNATURE_INVALID, UNKNOWN_READER, RATE_LIMITED. The bridge can log this to journalctl for the engineer to diagnose without contacting the worker.
replay is truewhen we've seen the same credentialId at the same reader in the last 2 seconds (a sticky-finger anti-bounce). We still return the original decision; the bridge can skip pulsing the relay a second time.
POST /access/reader-heartbeat
Bridge phones home every 5 min. API-key authenticated.
Authorization: Bearer mk_<your key>
{
"readerId": "BRIDGE-dxb1-stockroom",
"firmwareVersion": "rpi-1.0.0",
"ipAddress": "192.168.1.42"
}After ~5 min without a heartbeat the admin UI marks the reader offline. Heartbeats also auto-create the reader row on first sighting, so a fresh bridge appears in /admin/access → Readers within minutes of starting.
GET /access/audit
Every verify (GRANT or DENY) is written to the audit log, along with admin actions (zone create / update / delete, grant create / revoke, visitor create / revoke, integration add / remove).
200 OK
{
"data": [
{
"id": "evt_abc",
"at": "2026-05-28T08:32:10.123Z",
"type": "VERIFY",
"decision": "GRANT",
"userId": "user_abc",
"userName": "Layla Hassan",
"zoneId": "zone_xyz",
"zoneName": "Stockroom",
"readerId": "BRIDGE-dxb1-stockroom",
"source": "PHONE_NFC",
"reason": null
},
...
],
"meta": { "total": 4327, "page": 1, "limit": 50 }
}Query params: ?from, ?to (ISO 8601), ?userId, ?zoneId, ?limit (default 50, max 200).
Example: end-to-end GRANT flow with curl
Putting it together — what the wire traffic looks like for a worker minting a credential and then a bridge verifying it.
# 1. Worker mints credential (their JWT)
curl -X POST https://api.frontelio.com/api/v1/access/credential \
-H "Authorization: Bearer $USER_JWT"
# -> { credentialId: "eyJ...", expiresIn: "24h" }
# 2. Reader bridge hits verify (with API key)
curl -X POST https://api.frontelio.com/api/v1/access/verify \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"credentialId":"eyJ...","readerId":"BRIDGE-1","source":"PHONE_NFC"}'
# -> { decision: "GRANT", userId, zoneName, ... }Integrations
For wiring up external access platforms (Kisi, Salto, Brivo, HID Origo) — see the conceptual walkthrough in Integrations. API endpoints:
POST /access/integrations— connect a new provider. Body:{ provider, displayName, config }whereprovideris one ofkisi,salto,brivo,hid-origo, andconfigis a provider-specific JSON blob.POST /access/integrations/:id/sync-doors— pull the list of doors the provider exposes for this account.POST /access/integrations/:id/doors/:doorId/map— map an external door to a Frontelio Zone. Body:{ zoneId }(ornullto unmap).
POST /access/webhooks/:integrationId
Public endpoint — no auth header. Security is the HMAC signature the provider sends in X-Kisi-Signature / X-Signature(depending on provider), verified server-side against the integration's stored webhook secret. We return 200 even on dropped events so the provider doesn't hammer us with retries.
Content-Type: application/json
X-Signature: <hex HMAC-SHA256 of raw body, keyed by webhookSecret>
{
// provider-specific event payload
}