Skip to main content

UniFi Access

Commercial access-control for Ubiquiti's UniFi Access platform — UA-Hub, UA-Ultra, and Access running on UDM-Pro / UNVR consoles via UniFi OS. Uses the local Developer REST API on the console running Access, with a bearer API token. One GEM device row represents the Access controller; each zone represents a door.

Prerequisites​

  • UniFi Access 2.x or newer with the Developer API enabled. The API is served by the console running Access (UDM-Pro, UNVR, Cloud Key, UA-Ultra). Door hubs and readers do not serve it — a UA-Hub or G3 reader answers only on port 8080, and pointing GEM at one fails to connect.
  • API token, from the Access app. Mint one in the UniFi Access app under Settings → Advanced → API Token. The value is shown once — save it before navigating away. This token authorises every request.
    • The UniFi OS console's own Integrations → API Keys page issues a different kind of key (the one the Network and Protect integration APIs take). Access refuses it on every route with CODE_UNAUTHORIZED.
  • Network reach. TCP/12445 from GEM to the console running Access (HTTPS with a self-signed cert).
  • Doors already enrolled. GEM does not do door onboarding — enrol doors via the UniFi Access mobile / web UI first.

Setup​

1. Mint the API token​

  1. Open the UniFi Access app — not the console's Integrations / API Keys page.
  2. Go to Settings → Advanced → API Token.
  3. Click Create API Token. Name it something like gem-controller, and grant the door, system log, user and visitor scopes. For user sync (below) also grant the access policy, schedule and credential (PIN / NFC card) scopes.
  4. Copy the token. This is the only time it will be shown.

2. Add the device in GEM​

Under Admin → Devices → Add:

FieldValue
Driverunifi_access
Namee.g. access_ctrl
Attribute ipIP or hostname of the console running Access
Attribute tokenBearer token from step 1 (auto-encrypted)

Leave port (12445) and api_base at their defaults. UniFi OS consoles serve the Developer API on 12445 directly. The UniFi OS proxy path on port 443 (/proxy/access/api/v1/developer) refuses an Access token on current firmware; port and api_base stay settable for an older console where it still works.

Save. The driver will hit /doors to verify the token, then start the two polling loops. If the controller can't be reached at that moment (still booting after a power cut, off the network), the device shows offline and the driver tries again after 30 seconds, then 1, 2, 4 and 8 minutes, then every 15 minutes, until it answers. You don't need to reload the device.

3. Discover doors and create zones​

Open the device's Setup tab and click Discover. It lists every door the controller knows, marks the ones that already have a zone, and creates a door-lock zone for each one you tick — pick the subsystem (access_control, or door) in the list. Steps 3a and 4 below are the same thing done by hand.

3a. By hand​

Run the get_doors command from the Command Console. Each returned entry looks like:

{
"id": "2f2f42d0-1234-4a5b-9c8d-abcdef012345",
"name": "Front Lobby",
"floor_id": "…",
"door_lock_relay_status": "lock",
"door_position_status": "closed",
"is_bind_hub": true
}

The id field is what you use as the zone address.

4. Create zones — one per door​

Under Admin → Zones → Add, for each door:

FieldValue
DeviceThe Access device from step 2
Subsystemaccess_control (or door — either works with the state map)
AddressDoor UUID from get_doors

Save. The next poll cycle (default 60 s) will populate state, door_position, door_name, and online.

Attributes​

Required (device)​

NameTypeDescription
ipstringLAN IP or hostname of the Access controller.
tokenstring (secure)Bearer API token minted under Access → Settings → Advanced → API Token.

Optional (device)​

NameTypeDefaultDescription
portint12445TCP port. 443 only for an older console where the UniFi OS proxy path still takes an Access token.
api_basestring/api/v1/developerAPI path prefix. /proxy/access/api/v1/developer only with port 443, as above.
status_intervalint60000Milliseconds between door-state polls.
event_poll_intervalint30000Milliseconds between event-log polls. 0 disables event polling.
unlock_secondsint5Fallback unlock duration when a zone-level override is not set. Bounded 1-60. Note: Access enforces its own hold time regardless — this is a hint that surfaces on the response payload.
sync_user_emailboolfalseCopy GEM users' emails to Access. Access then sends them a Ubiquiti invitation once a rule gives them door access. See Email.
released_state_msint5000How long zone.state stays at released after an unlock before the next poll snaps it back.
request_timeoutint10000Milliseconds to wait for each API reply. A value that isn't a positive number uses the default.

If a poll interval or hold time can't be read as a number, or is larger than a timer can hold (about 24.8 days), the driver uses that setting's default.

Optional (zone)​

NameTypeDescription
unlock_secondsintPer-door override of the device-level default.

Populated by the driver​

The polling loops write these attributes onto each door zone:

NameSourceNotes
statedoor_lock_relay_statuslocked / unlocked / released (transient).
door_positiondoor_position_statusopen / closed, or none when no door-position sensor is wired to the hub.
door_namenameThe friendly name from Access.
onlineis_bind_hubtrue if the door is bound to a reachable Access hub.
last_event/system/logsMost recent event type, e.g. access.door.unlock.
last_event_at/system/logsISO timestamp of the event.
last_event_user/system/logsWho the event names — a user, or the API token's name for an unlock sent from GEM.
last_event_result/system/logsACCESS (granted) or BLOCKED (refused). The event type alone doesn't tell them apart.
last_event_message/system/logsThe text the Access log shows, e.g. Access Granted (Remote).
last_event_method/system/logsHow the door was opened or tried: NFC, PIN_CODE, REMOTE_THROUGH_UAH (an unlock from the app or GEM), and so on. Every unlock has the same event type, so this is what tells a badge from a remote unlock.

Each new log entry for a door is written in order, so attribute history on last_event holds the door's event log. When the driver starts, it reads the last 24 hours and publishes only each door's newest event, rather than replaying the backlog. Each poll re-reads the minute behind the last event it saw, because an entry from a door hub can be logged a few seconds after a newer one; entries already handled are skipped. Admin Activity and Updates entries (configuration edits and firmware updates that name a door) are not written to the door zone.

Commands​

CommandArgsNotes
unlockaddress, duration (optional)Buzz-open the door (one-shot). Duration is a hint — Access enforces its own hold time.
lock / reset_lock_ruleaddressReset the lock rule to the normal schedule. Locks the door if a keep_unlock was active.
keep_unlockaddressHold the door unlocked until reset. Use for propping doors open.
keep_lockaddressHold the door locked, ignoring valid credentials. Lock-down.
get_doors—Enumerate all doors. Cache is refreshed with the response.
get_statusaddressRead a single door.
get_eventssince (optional epoch ms; seconds also accepted), topic (optional)Read the system log. topic is one of all (default), door_openings, critical, updates, device_events, admin_activity, visitor.
get_users—List Access users.
get_visitors—List active visitors.
refresh—Force a door status poll now.
raw_requestpath, method, bodyEscape hatch — pass-through to any /api/v1/developer/* endpoint.
sync_users—Run the user sync now (see User sync) and return what it did.
get_access_policies—List Access access policies. The ones GEM manages are named GEM <key>.
get_schedules—List Access schedules.
get_cards—List the NFC cards enrolled in Access, with each card's UID (nfc_id), printed number and user.
enroll_cardusername (optional), reader (optional), timeout (optional, seconds, default 30, max 120)Wait for a card to be tapped on an Access reader and return it. With username, the card becomes that GEM user's RFID card. See Enrolling a card from GEM.
get_readers—List the Access devices that can read a card for enroll_card.

After lock, keep_unlock or keep_lock is accepted, the driver re-reads the doors 1 s and 5 s later, so the zone's state follows within seconds instead of waiting for the next poll. The relay takes a few seconds to drop back to locked after a reset.

User sync​

GEM's Access Control Rules drive who can open which door, the same way they do for 2N, Brivo and Suprema devices. Point a rule's Access Device at the UniFi Access device and GEM keeps Access in step:

GEMUniFi Access
A user named by the rule (directly, through an access group, or through a role)An Access user, created if needed
The user's nameThe Access user's name
The user's email (only with Sync User Email on)The Access user's contact email (user_email). See Email
The user's PINThe user's PIN code
The user's RFID cardThe matching card already enrolled in Access, assigned to the user
The rule's days and hoursA schedule named GEM <key>
The rule's doorAn access policy named GEM <key>: that schedule over that door

Which door a rule grants. If the rule's action is a device command on a zone of this Access device (e.g. unlock → Zone: Front Door), the rule grants that door. If the action is a macro or none, it grants every door that has a zone on this device. If the action targets a zone on a different device (a relay on another controller), the rule grants no Access door, and the driver logs it.

When it runs. When the device connects, after any rule on this device is added, changed, disabled or deleted, and when a user's credentials change or they are enabled. sync_users runs it on demand. Request-to-exit and call-button rules are not grants and are skipped.

What it never touches. Policies and schedules whose names don't start with GEM are never changed or removed, and a user keeps every policy assigned in Access by hand. A device with no rules, which never granted anyone anything, is not touched at all. A sync that can't read the rules, or a role rule read before GEM's user list has loaded, stops with an error rather than revoking anyone.

Matching existing Access users. A GEM user is linked to an Access user the first time they sync: by a previous GEM link, then by email, then by full name if exactly one Access user has it. An Access user the sync created for a different GEM user is never taken by email or name, so two GEM users who share an address or a name get separate Access users. Only when none match is a new Access user created. The link is kept in the GEM user's unifi_access_user_id_<device id> attribute.

Taking access away.

  • A user taken off every rule on the device loses the GEM policies. Their Access user and any hand-assigned policies stay.
  • A user disabled in GEM, revoked, or past their validity window is deactivated in Access. Re-enabling them reactivates them with their credentials as they were.
  • A user deleted in GEM is deactivated and then deleted in Access.

Access enforces the schedules on its own hub, so access keeps working while GEM is offline.

Cards​

Access identifies a card by a token it issues when the card is read on a reader, not by the card number. The sync assigns a GEM user's card only when Access already knows that card: enroll it from GEM with enroll_card (below), tap it on a reader from the Access app, or import it there. It matches the GEM card number against each enrolled card's UID (04:A1:B2…, 04a1b2… and the decimal form are all the same card) or its printed number. A card that isn't enrolled, or that belongs to another Access user, is logged and left alone. If Access refuses the assignment, that is logged too and the user's PIN and policies are still pushed. get_cards lists what Access has.

Enrolling a card from GEM​

Run enroll_card with the GEM user's username, then tap the card on the reader within the timeout. The reader prompts for a card while it waits.

  1. The card is read, and its UID becomes the GEM user's RFID credential. This uses the same check as the user editor, so a card another GEM user already holds is refused.
  2. If an access rule on this device names the user, the sync assigns the card to their Access user straight away, and the result says assigned_in_access: true.
  3. If no rule names them yet, the card is saved on the GEM user only, and Access assigns it as soon as a rule does.

A card that already belongs to a different Access user is refused. With no username, the command only reads the card and returns its UID, printed number and current owner, which is a quick way to identify an unlabelled card. reader is needed only when the site has more than one reader (get_readers lists them). If nothing is tapped before the timeout, the reader session is closed.

Email​

Email sync is off by default, because of what Access does with an address. Once a user with an email has door access, Ubiquiti sends them an invitation email to set up the UniFi Access app. Setting the address or a PIN alone sends nothing; granting access does.

Turn on Sync User Email (sync_user_email) on the device when the site's users should get that invitation. The GEM user's email then becomes the Access user's contact address (user_email):

  • A blank GEM email leaves whatever address Access already has.
  • The UniFi account email that people sign in with is never changed.
  • Access refuses an address another Access user already has. The sync logs that (without the address) and still pushes the user's PIN, card and policies.

With it off, GEM still uses an existing Access user's email to link them to the matching GEM user; it just never writes one.

PINs​

Access refuses a PIN that another Access user already holds. It can also answer success for a PIN it doesn't store. After every PIN write, the sync reads the user back and logs a PIN that didn't land (never the PIN itself). GEM remembers what it last pushed, so an unchanged PIN isn't sent again.

Zone address format​

Every zone address is a UniFi Access door UUID (36-character, hyphenated). Get them via get_doors. Spaces before or after the address are ignored, and upper- or lower-case UUIDs both match their door when the driver reads status.

The door commands refuse an address that can't name a door, and send nothing to the controller:

  • a blank address
  • a [token] that was never filled in
  • the words undefined or null
  • . or ..

Command results​

A door command only reports success when the controller confirms it. The following come back as errors:

  • A refusal with a 200 status. The Developer API answers {"code": "...", "msg": "..."}, and any code other than SUCCESS is returned as an error such as unifi access CODE_DEVICE_DEVICE_OFFLINE: device offline. The controller's reply is kept alongside the error.
  • A reply that isn't JSON. This usually means port or api_base points at the UniFi OS web page instead of the API.

In both cases an unlock does not mark the zone released. A door poll that gets either reply marks the device offline.

Known limitations​

  • Unlock duration is Access-controlled. The Developer API's unlock endpoint is a one-shot buzz whose hold time is set by the door schedule in Access, not by anything passed on the API call. To hold a door open longer, use keep_unlock and later lock (reset).
  • Card enrollment needs a reader. enroll_card reads a card on an Access reader; there is no way to add a card by typing its number (Access's card import didn't accept any format tried).
  • Holiday calendars are not mirrored. A GEM rule's holiday calendar applies to rules GEM evaluates; the Access schedules GEM creates use Access's default holiday group.
  • Unused GEM policies are not deleted. When a rule's hours or door change, a new GEM <key> policy is created and the old one is simply no longer assigned. Delete stale ones in the Access app if you want a tidy list.
  • Event log is polled, not streamed. The Developer API exposes a WebSocket for events, but this driver polls /system/logs (a POST, with since in epoch seconds) from a watermark instead. Set event_poll_interval=0 to skip event polling if you only care about door state.
  • Doorbell events don't reach a door zone. A ring or missed call on a G3 reader or intercom names the reader, not a door, so it is skipped.
  • UniFi Identity Enterprise / SSO is not consumed. The driver uses the local API token, not the tenant OAuth.

Troubleshooting​

http 401 — check that the API token is valid. The token was revoked, expired, or copied incorrectly — or it is a UniFi OS console API key rather than a token from the Access app. Mint a new one in the Access app and paste it into the token attribute. The same 401 comes back from the port-443 proxy path on current firmware; set port back to 12445 and api_base to its default.

unexpected non-json reply from the controller. The request reached a web server that answered with a page instead of the API, typically UniFi OS on port 443. Use port 12445 and the default api_base.

http 404 on connect. The api_base path is wrong. /api/v1/developer on port 12445 is correct for the console running Access.

Can't connect at all. Check that ip is the console running Access, not the door hub or reader — those don't serve the API.

door_position reads none. The door doesn't have a position sensor wired to the hub. state still follows the lock relay; only door_position needs the sensor.

Events stop populating. Check the token still has read scope. If you disabled Developer API access on the controller after adding it here, the polling loop logs a 401 warning each cycle. Set event_poll_interval=0 while token access is out.

get_users returns an empty array but the Access UI shows users. Some tenants scope users by site — the Developer API's /users returns the currently-active site by default. Use raw_request with a site-scoped path if you need cross-site enumeration.

Follow-ups​

  • Consume the Developer API WebSocket (/subscribe/events) for push-based events instead of polling.
  • Record Access-side unlocks against the matching GEM rule (last_triggered, access log).
  • Surface schedule information on zones (currently only state / door_position are polled).
  • Sample-and-cache door photos where cameras are paired to doors.