Browse docs
API · /access

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

MethodPathAuthDescription
GET/access/zonesBearer JWTList Zones in this tenant (optionally filter by outletId).
GET/access/zones/:idBearer JWTGet a single Zone with reader bindings.
POST/access/zonesBearer JWTCreate a Zone.
PATCH/access/zones/:idBearer JWTUpdate a Zone.
DELETE/access/zones/:idBearer JWTDelete a Zone (cascade-revokes any active grants).
GET/access/grantsBearer JWTList Grants (filter by userId or zoneId).
GET/access/users/:userId/grantsBearer JWTList all Grants held by a user.
POST/access/grantsBearer JWTCreate a Grant.
POST/access/grants/bulkBearer JWTCreate multiple Grants in one call.
POST/access/grants/:id/revokeBearer JWTRevoke an active Grant.
DELETE/access/grants/:idBearer JWTDelete a Grant row (hard delete; use revoke for soft).
GET/access/visitorsBearer JWTList active visitor passes.
POST/access/visitorsBearer JWTCreate a time-limited visitor pass.
DELETE/access/visitors/:inviteCodeBearer JWTRevoke a visitor pass.
POST/access/visitors/:inviteCode/redeemPublic · 5/5min/IPVisitor self-redeem via magic link.
GET/access/api-keysBearer JWTList per-tenant API keys (secrets are NOT included).
POST/access/api-keysBearer JWTMint a new API key. The secret is returned ONCE.
POST/access/api-keys/:id/revokeBearer JWTRevoke an API key. Cannot be undone.
POST/access/credentialBearer JWT (user)Mint a 24h credential JWT for the current user.
POST/access/verifyBearer API keyHOT PATH. Reader presents a credential; we return GRANT or DENY.
POST/access/reader-heartbeatBearer API keyBridge heartbeat. Every 5 min so the admin UI shows online/offline.
GET/access/readersBearer JWTList observed readers with online status.
GET/access/auditBearer JWTRecent door events, newest first (every verify tap, plus a REVOKED row per revoked grant).
GET/access/reports/who-whereBearer JWTWho-was-where report for a date range.
GET/access/integrationsBearer JWTList external reader integrations (Kisi / Salto / Brivo / HID).
POST/access/integrationsBearer JWTConnect a new external access provider.
PATCH/access/integrations/:idBearer JWTUpdate integration config.
DELETE/access/integrations/:idBearer JWTRemove an integration.
POST/access/integrations/:id/test-connectionBearer JWTValidate credentials against the provider.
POST/access/integrations/:id/sync-doorsBearer JWTPull the latest door list from the provider.
GET/access/integrations/:id/doorsBearer JWTList discovered external doors.
POST/access/integrations/:id/doors/:doorId/mapBearer JWTMap an external door to a Frontelio Zone.
POST/access/webhooks/:integrationIdPublic · HMAC-verifiedInbound 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.

POST /access/zones
{
  "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."
}
201 Created
{
  "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.

POST /access/grants
{
  "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:

POST /access/grants/bulk
{
  "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.

POST /access/visitors
{
  "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
}
201 Created
{
  "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.

POST /access/api-keys
{
  "name": "DXB-Marina — Door bridges"
}
201 Created
{
  "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.

POST /access/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.

POST /access/verify
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
}
200 OK (GRANT)
{
  "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"
}
200 OK (DENY)
{
  "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 no readerId).
  • 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's expiresAt has passed. Only the first refused tap says this: the grant is then marked expired, and later taps read No access to this zone.
  • Zone closed today — the zone's hoursJson has 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.

POST /access/reader-heartbeat
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.

GET /access/audit?from=2026-05-01&to=2026-05-28&limit=50
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.

bash
# 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 } where provider is one of kisi, salto, brivo, hid-origo, and config is 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 } (or null to 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.

POST /access/webhooks/integ_abc
Content-Type: application/json
X-Signature: <hex HMAC-SHA256 of raw body, keyed by webhookSecret>

{
  // provider-specific event payload
}