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 | Recent door events, newest first (every verify tap, plus a REVOKED row per revoked grant). |
| 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 and readerIds (at least one) are required. hoursJson limits when the zone's readers accept anyone: each day is a list of ["HH:MM", "HH:MM"] windows (24-hour, in your company's timezone), and a day you leave out — or give an empty list — is treated as closed. A window whose end is earlier than its start wraps past midnight. Omit hoursJson for 24/7 access.
{
"name": "Back of House — Stockroom",
"outletId": "0b6f2c1e-8a47-4d93-b5e1-7f3a9c2d4e68", // optional — omit for a tenant-wide zone
"readerIds": ["BRIDGE-dxb1-stockroom"],
"hoursJson": { // optional — omit for 24/7 access
"MON": [["07:00", "23:00"]],
"TUE": [["07:00", "23:00"]],
"WED": [["07:00", "23:00"]],
"THU": [["07:00", "23:00"]],
"FRI": [["07:00", "23:00"]]
},
"description": "Dry-goods stockroom. Manager-only outside opening hours."
}{
"id": "9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90",
"tenantId": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
"outletId": "0b6f2c1e-8a47-4d93-b5e1-7f3a9c2d4e68",
"name": "Back of House — Stockroom",
"description": "Dry-goods stockroom. Manager-only outside opening hours.",
"readerIds": ["BRIDGE-dxb1-stockroom"],
"hoursJson": {
"MON": [["07:00", "23:00"]],
"TUE": [["07:00", "23:00"]],
"WED": [["07:00", "23:00"]],
"THU": [["07:00", "23:00"]],
"FRI": [["07:00", "23:00"]]
},
"active": true,
"createdAt": "2026-05-28T08:32:10.123Z",
"updatedAt": "2026-05-28T08:32:10.123Z",
"deletedAt": null,
"outlet": { "id": "0b6f2c1e-8a47-4d93-b5e1-7f3a9c2d4e68", "name": "DXB Marina", "code": "DXB1" }
}Grants
A Grant ties a User (or a Role) to a Zone. Give a grant an expiresAt and it stops working automatically at that moment; omit it for a grant that never expires. A Zone's own hoursJson separately limits the days and times of day its readers accept anyone.
POST /access/grants
userId and zoneId are required and must be UUIDs. expiresAt must be an ISO 8601 timestamp in the future.
{
"userId": "3f2b8a1e-5c4d-4e7a-9b1c-0a2d6e8f4b71",
"zoneId": "9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90",
"expiresAt": "2027-06-01T00:00:00Z", // optional — auto-expires; omit for a permanent grant
"note": "6-month contractor" // optional
}POST /access/grants/bulk
Create grants for a list of users in one call. Common at onboarding. expiresAt and note are accepted here too and apply to every grant created:
{
"userIds": [
"3f2b8a1e-5c4d-4e7a-9b1c-0a2d6e8f4b71",
"7a9c1e4b-2d68-4b35-8f0a-5e3c7b9d1a26",
"e15b8d3f-6a42-49c7-b0e9-1d4f7a2c8b53"
],
"zoneIds": [
"9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90",
"2c6e4a8b-9f13-4d57-a3b8-7e0d5c1f9a42"
]
}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
name, zoneIds (at least one UUID) and expiresAt (an ISO 8601 timestamp in the future) are required — there are no permanent visitor passes. If you supply an email and/or phone we send the magic link by email and/or WhatsApp; the response reports whether each delivery succeeded.
{
"name": "ACME Pest Control — Ahmed",
"zoneIds": [
"9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90",
"2c6e4a8b-9f13-4d57-a3b8-7e0d5c1f9a42"
],
"expiresAt": "2027-06-01T11:00:00Z",
"email": "ahmed@example.com", // optional — magic link is emailed
"phone": "+971501234567" // optional — magic link is sent by WhatsApp
}{
"inviteCode": "a3F2X1bC9kQ7mZ4vT8nR2wYd5LpH6sJe",
"magicLink": "https://app.frontelio.com/visitor/a3F2X1bC9kQ7mZ4vT8nR2wYd5LpH6sJe",
"expiresAt": "2027-06-01T11:00:00.000Z",
"emailSent": true,
"whatsappSent": true,
"grants": [
{ "id": "5b8d2f6a-1c73-4e90-8a4b-3d7e9f1c2b60", "zoneId": "9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90", "zoneName": "Kitchen", "status": "ACTIVE" },
{ "id": "c0a4e7d1-8b26-4f39-9d5c-6a1b3e8f2d74", "zoneId": "2c6e4a8b-9f13-4d57-a3b8-7e0d5c1f9a42", "zoneName": "Storage", "status": "ACTIVE" }
]
}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, and only until the pass's expiresAt.
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
The only field is name (required) — a label for the admin UI. API keys are per tenant and cannot be scoped to a single outlet.
{
"name": "DXB-Marina — Door bridges"
}{
"id": "6e1a9b3c-4d70-4f25-8c9e-2a5b7d0f3e81",
"name": "DXB-Marina — Door bridges",
"key": "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)
201 Created
{
"credentialId": "eyJhbGciOiJIUzI1NiIs... (24h JWT)",
"expiresIn": "24h"
}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" // optional label for the audit log (max 40 chars); defaults to BRIDGE_HTTP
}{
"decision": "GRANT",
"userId": "3f2b8a1e-5c4d-4e7a-9b1c-0a2d6e8f4b71", // omitted for a visitor pass
"userName": "Layla Hassan", // a visitor pass returns the visitor's name
"zoneId": "9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90",
"zoneName": "Back of House — Stockroom"
}{
"decision": "DENY",
"reason": "Outside allowed hours"
}A DENY carries only decision and a short, human-readable reason. Every decision is a 200; a non-2xx means the request itself failed (bad API key, network, server). reason is one of:
Unknown reader— the reader isn't bound to any zone (or the request had noreaderId).Zone inactive— the zone has been switched off.Unknown credential— the credential couldn't be decoded for this tenant (bad signature, expired, wrong tenant).No access to this zone— no active grant for this person (never granted, or revoked). A visitor pass that doesn't cover this zone lands here too.Access expired— the grant'sexpiresAthas passed. Only the first refused tap says this: the grant is then marked expired, and later taps readNo access to this zone.Zone closed today— the zone'shoursJsonhas no window for today.Outside allowed hours— today has windows, but the time now is outside all of them.
Branch on decision, not on the wording of reason: it is for logs and for the person at the door, and can be reworded. Log it (journalctl on the bridge) so an engineer can diagnose a refusal without contacting the worker.
replay is present, and true, only when we've seen the same credentialId at the same reader in the last 2 seconds (a sticky-finger anti-bounce). We return the original decision without writing a second audit row; the bridge can skip pulsing the relay a second time. Fresh decisions omit the field.
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"
}The shipped bridge beats every 5 min. After 15 min without a heartbeat (three missed beats) 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
Door events, newest first: every verify tap (GRANT or DENY), plus a REVOKED row whenever a grant is revoked — by an admin, or automatically when its user is removed. A REVOKED row is a lifecycle record, not a reader tap.
200 OK
[
{
"id": "5b8d2f6a-1c73-4e90-8a4b-3d7e9f1c2b60",
"tenantId": "c4b7a2e9-1f30-4d58-8e6a-2b9d5f1c7a34",
"userId": "3f2b8a1e-5c4d-4e7a-9b1c-0a2d6e8f4b71",
"zoneId": "9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90",
"credentialId": "eyJhbGciOiJIUzI1NiIs...",
"readerId": "BRIDGE-dxb1-stockroom",
"eventType": "GRANTED",
"reason": null,
"occurredAt": "2026-05-28T08:32:10.123Z",
"source": "PHONE_NFC",
"user": { "id": "3f2b8a1e-5c4d-4e7a-9b1c-0a2d6e8f4b71", "fullName": "Layla Hassan" },
"zone": { "id": "9d0e7c1a-3b52-4f86-a1d4-6c2f8b5e7a90", "name": "Back of House — Stockroom" }
}
]eventType is one of GRANTED, DENIED_NO_GRANT, DENIED_EXPIRED, DENIED_OUTSIDE_HOURS, DENIED_ZONE_INACTIVE, UNKNOWN_CREDENTIAL, UNKNOWN_READER, REVOKED. userId/user are null when the credential couldn't be tied to a person (and for visitor passes); zoneId/zone are null when the reader wasn't bound to a zone.
Query params (all optional): ?from, ?to (ISO 8601), ?userId, ?zoneId (UUIDs), ?limit (default 200, max 1000). There is no paging: narrow the window with from/to to read further back.
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
}