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 JWTPaginated audit log (every verify, grant, revoke).
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

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."
}
201 Created
{
  "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

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:

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

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
}
201 Created
{
  "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

POST /access/api-keys
{
  "displayName": "DXB-Marina — Door bridges",
  "outletId": "outlet_dxb1"          // optional — scopes the key
}
201 Created
{
  "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.

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

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"               // or PHONE_QR | VISITOR | CARD
}
200 OK (GRANT)
{
  "decision": "GRANT",
  "userId":   "user_abc",
  "userName": "Layla Hassan",
  "zoneName": "Back of House — Stockroom",
  "reason":   null,
  "replay":   false
}
200 OK (DENY)
{
  "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.

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

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

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
}