Skip to main content

REST API

GEM exposes a built-in REST API for external systems that need to authenticate, read data, and dispatch commands or macros. The API is enabled by default ("Enable REST API" during Installation) and gated by the same role-based access control as the socket API.

For custom HTTP endpoints with arbitrary handlers, see Web Services — those are user-defined scripts; the REST API documented here is the fixed contract.

Base URL

All endpoints are mounted under /api/ on the controller (e.g., https://gem.example.com/api/token).

Authentication

POST /api/token

Exchanges a username + password for a session token.

Request body (JSON):

{
"username": "user@example.com",
"password": "secret",
"two_factor_token": "AB23KP"
}

two_factor_token is only required when the user has 2FA enabled and a code has already been emailed (see below).

Success response:

{
"token": "…32-character token…",
"expires": 3600000,
"sites": [{"id": 1, "name": "main"}]
}

2FA challenge response — returned on the first call for a 2FA-enabled user:

{
"error": "two_factor_auth",
"message": "Please enter the 2FA code",
"ttl_seconds": 300
}

A 6-character alphanumeric code is emailed to the user (uppercase A–Z + 2–9, excluding visually ambiguous characters like 0/O and 1/I/L). Resubmit /api/token with the same credentials plus two_factor_token to complete login. The compare is case-insensitive and the code is single-use (cleared on successful login).

ttl_seconds is both the prompt TTL to display and the window the code is actually accepted in — a code submitted after it lapses is refused as two factor fail. Configure with cfg.api_2fa_ttl_seconds (default 300). The resulting session's lifetime is separate, governed by cfg.api_token_life (default 1 hour). The issue time is stored on the account, so restarting the controller no longer resets the clock on a code that has already been mailed; a code whose issue time cannot be established is treated as expired and a fresh one is issued on the next attempt.

The same gate covers the UI login

This challenge is no longer specific to the REST API. The socket login every browser, panel and mobile client uses runs through the same code, so switching Two-Factor Authentication on for a user now applies everywhere they sign in — not only to API integrations. PIN logins are exempt. See Users.

warning
/api/token never waives the second factor

The role setting Skip 2FA on Local Network (Roles) applies only to the socket login, where the connection's real peer address can be established. This endpoint always challenges a 2FA account, whatever the request appears to come from — which is what makes the waiver local-only, since the cloud proxy authenticates remote users through here.

Error envelope:

{"error": "unauthorized", "message": "invalid credential"}

Possible message values: missing credential, invalid credential, no sites found, invalid roles, remote access not permitted, 2FA is enabled but user has no email address, two factor fail, malformed body.

Local-only roles are refused before 2FA

A user whose roles all have Allow Remote off (Roles) is refused a token when the request is provably remote — after the password check, so the answer does not reveal whether the password was right, and before the 2FA gate, so a denied login does not consume an emailed code. The refusal is logged with reason remote_denied. The check re-runs on every subsequent request: a token minted on the LAN is dropped the moment the same holder is off-site (later calls fail as invalid_session), and revoking Allow Remote mid-session takes effect on the holder's next request. Remoteness is judged server-side from the connection's real peer address and the cloud-tunnel marker — the same three-valued test the socket transport uses — and when it cannot tell, nothing is denied.

note

For backwards compatibility with older clients, authentication failures currently return HTTP 200 with an {error, message} envelope rather than 4xx status codes. Newer endpoints (/api/control, /api/data) do use proper status codes.

Using the token

Pass the token on subsequent requests using either header form:

gem-api-token: <token>
Authorization: Bearer <token>

POST /api/logout

Revokes the current session token. Idempotent — returns {success: true} whether or not the token matched.

POST /api/session_handoff

Exchanges a valid API token for a single-use nonce that signs the same user into the GEM web UI without a second credential prompt. This exists for the cloud proxy, which authenticates remote users by forwarding their credentials to /api/token on this controller and would otherwise have to send them to GEM's own login screen to do it all again — including a second emailed 2FA code, since the first is consumed on use.

Requires an authenticated session (the token from /api/token); nothing else authorizes it. Being tunnel traffic is not sufficient and is never treated as proof.

Success response:

{"nonce": "…48-character nonce…", "expires_in": 60}

The nonce is single-use and expires in 60 seconds — long enough for one browser redirect. It is intended to be handed to the browser as an HttpOnly cookie, not placed in a URL, since anything in a URL lands in browser history and every proxy access log.

Redeeming it re-checks the account against live state rather than trusting the nonce: the user must still exist, still be inside their validity window, and hold no role with Allow Remote off — one local-only role refuses the handoff. A refused handoff still consumes the nonce. Returns 401 {"error": "unauthorized"} when the token is missing/invalid or the account has since gone away.

GET /api/me

Returns the current user identity for the supplied token:

{
"id": 42,
"username": "nathan",
"roles": ["admin"],
"sites": [{"id": 1}],
"two_factor_auth": true,
"issued": 1715000000000
}

Control Endpoints

POST /api/control/command

Dispatches a zone command. Body matches the standard command shape — a target (zone_id, zone by name, device_id or device by name), the verb in command, and any parameters in args:

{"zone_id": 11, "command": "level", "args": {"level": 100}}

Requires the command API permission on the user's role (see Roles).

POST /api/control/macro

Runs a macro by id or name:

{"macro_id": 12}

Requires the macro API permission.

Non-POST methods return 405 Method Not Allowed with an Allow: POST header.

Elevated devices and macros

The command / macro permissions decide whether a session may call these endpoints at all. They do not override the per-entity Elevated flag on a device or macro — that check runs afterward, and it is the same check the socket API applies, so a target that is admin-only over the socket is admin-only over REST.

For a non-elevated session:

RequestResult
Command resolving to a device flagged Elevated (directly, or through one of its zones)403 {"error": "unauthorized"}, reason elevation_denied
Macro flagged Elevated — including the {"macro_id": …} redirect shape sent to /api/control/command403 {"error": "unauthorized"}, reason elevation_denied
Macro that does not exist403 {"error": "unknown macro"}, reason unknown_macro

Elevated sessions are unaffected. Nothing is dispatched when the gate refuses — the command never reaches the driver. See Elevated Devices & Macros.

Both gates read the target from the top level of the request body (zone_id, zone, device_id, device). A zone_id placed inside args names nothing they can judge, so for a non-elevated session it is dropped before dispatch and replaced with the zone the server itself resolved — target zones at the top level, where the request is checked. Elevated sessions and internal callers are unaffected. See Elevated devices and macros.

A non-elevated command is also judged against the user's UI scope: when every UI the user's roles allow carries a zone scope, a command targeting a zone or device on none of those UIs is refused with 403 {"error": "unauthorized"}, reason scope_denied — the same gate the socket API applies, so both transports answer identically. Sites whose UIs express no scope are unaffected. A macro run is not scoped as a whole, but a Command step inside it whose target came from the args the request supplied is checked against the same scope after substitution and refused with not authorized. See UI scope for commands.

Data Endpoints

GET /api/data/:entity

Queries an entity using URL query parameters as the where clause. Numeric query strings are automatically coerced to numbers.

GET /api/data/zone?subsystem_id=11
GET /api/data/device?id=42

Requires the query API permission.

POST / PATCH / DELETE on /api/data/:entity currently return 501 Not Implemented — use the socket API for writes.

Read guards

The query permission grants access to the generic read surface, not to everything in the database. The same guards the socket query handler enforces apply here:

A non-elevated session may only read an allowlisted table. The list is a positive allowlist shared with the socket query handler, so a table is closed unless it is named here and a newly added model is closed by default:

attribute, av_source, av_zone, channel, channel_provider, command, device, subsystem, ui, ui_control, ui_macro, ui_page_ui, ui_page_widget, ui_theme, ui_widget, ui_zone, ui_zone_group_ui, zone

Anything else — auth_user, auth_role, ui_client, license, user_driver, credential, access_credential, backup_settings, access_log, access_control, access_group, request_history, web_service, macro, macro_step, and every other model — returns 403 with {"error": "unauthorized"} for a non-elevated session, logged in Request History with reason elevated_table. Elevated sessions read any table. macro and macro_step came off the allowlist because a step's data is free-form JSON that can carry author-time secrets — Web Request auth headers, Run Script sources; the panel-side scene editors read through purpose-built socket functions that return only a zone/level projection, so nothing non-admin needs the raw rows.

Secret-bearing columns are always blanked, for every caller including elevated ones. Any row returned by this endpoint has hash, salt, pin, rfid, duress_pin, two_factor_token, token, push_subscription, private_key, api_key, secret, password, bearer_token, and credential_hash set to null. Any string column whose name ends in _token, _secret, _password, _passwd, or _hash is blanked the same way, so a newly added secret column is covered without being listed. Non-secret columns on the same row (username, email, …) come back normally, and boolean flags with matching names (e.g. must_change_password) are left alone.

Secure attribute values are never returned to a non-elevated caller. On /api/data/attribute, a non-elevated request has secure=false forced into the where clause, and any row that survives is scrubbed if it is flagged secure or carries a sensitive name — value and previous_value come back null. An explicit ?secure=true returns an empty array rather than silently inverting the filter. Elevated sessions read secure values as before.

GET /api/data/attribute?system_target=device&target_id=42
→ non-secure rows only; any secure/sensitively-named row has a null value

A secret nested inside a JSON attribute value is blanked too. Some attributes hold a JSON blob whose name reads as ordinary configuration while a leaf inside it is a credential — a sync profile carrying an SFTP password, or the site's Web Push signing key. For a non-elevated caller those leaves come back null while the rest of the blob stays usable, so an admin screen still gets its host, port and schedule. This applies to previous_value as well, which carries the same blob one write behind.

An attribute value cannot be filtered on. value is not a secret by name — it is a secret on the rows whose own name says so — so this is a rule of its own, alongside the redacted-column rule below. Scrubbing the value on the way out still leaves whether the row came back as the answer, and about ten requests per character recovers a door PIN or card number, every one of them a 200. For a non-elevated session a query on /api/data/attribute that filters on value — directly or through _like, _in, _gt, _between, _contains, _notequal — is answered with an empty array unless it also pins a specific, non-sensitive name, and logged with reason secure_filtered. So ?name=state&value=on ("which zones are on") still works, while a value sweep across every row does not. previous_value is refused outright: it is a second copy of the same value with no read use here. Elevated sessions are unaffected.

Merged secure attributes are stripped too, at every depth. _instance=true merges each row with its live in-memory object, which the drivers hold with secure values already decrypted — device passwords, camera credentials, integration API keys, LoRaWAN session keys. For a non-elevated caller those merged keys come back null; everything else in the merge (state, level, IP address, and the rest of the control surface's working data) is untouched. The flag that controls this is set by the server, not read from the request, so it cannot be turned off by passing it in the query string.

The merge is a graph, not a flat row: a device carries its zones, and each of those carries its own decrypted attributes. The strip follows it down, so a camera zone's password and stream URL and a LoRaWAN zone's session keys are blanked in a device query exactly as they would be in a zone one. Nothing the control surface renders from — labels, state, addresses — is affected.

GET /api/data/device?_instance=true
→ live state merged in; password / api_key / app_key and other secure values null,
on the device and on every zone hanging off it

A redacted column cannot be filtered on either. Blanking a value on the way out still leaves the column usable as a predicate, and a predicate over a secret is an oracle — a _like filter plus _count reads a PIN back one character at a time. For a non-elevated session a query whose where clause names any of the columns above — directly or through _like, _in, _gt, _between, _contains, _notequal or _or — is refused with 400 {"error": "invalid query"}, logged with reason redacted_filter. Boolean flags with matching names (must_change_password) are still filterable, and elevated sessions are unaffected.

Column projections cannot rename. The optional _fields projection must be a plain array of column-name strings for a non-elevated session. A [column, alias] pair — which Sequelize would emit as SELECT "column" AS "alias" — is refused with 400 {"error": "invalid query"}, logged with reason invalid_projection, because the scrubs above match on the returned key name and an alias would carry a redacted value out under a name no list has heard of. Elevated sessions may alias.

Row limits

A generic query is never unbounded — attribute_history alone runs to hundreds of thousands of rows on a single residence. Pass _limit to control the cap:

CaseApplied limit
No _limit supplied1000 (default)
_limit=5050
_limit=99999910000 (maximum)
_limit=0, negative, or non-numeric1000 (default)
GET /api/data/attribute_history?name=power&_limit=5000

To walk a result set larger than the maximum, page with _offset:

GET /api/data/attribute_history?name=power&_limit=10000&_offset=10000

Ordering

Pass _orderby to sort the result: a comma-separated list of columns, each optionally suffixed with =desc (ascending is the default). Every column named is applied, in order, so a paged walk stays stable from one page to the next:

GET /api/data/attribute?system_target=zone&_orderby=name,target_id=desc&_limit=500

Public Endpoints

These do not require authentication.

GET /api/health

Liveness probe. Always returns {"ok": true}.

The reply is sent with Cache-Control: no-store and answers cross-origin: the request's Origin is reflected with credentials allowed, so a page loaded from one of the controller's addresses can read the answer from another. The mobile app's local failover probe relies on this to confirm that the controller itself is answering on a path, not merely a proxy in front of it (see Mobile App). The body carries no per-session data, which is what makes reflecting the origin safe here.

GET /api/version

Returns the controller version string:

{"version": "2.0.4521"}

RBAC

The REST API enforces role-based access control on command, macro, and query using the same api rule list as the socket API. Configure under Roles:

allow api command, macro, query
deny api delete_macro

A * wildcard in allow grants every API function unless explicitly denied.

Role and account changes reach tokens that are already issued

A token names the account; it does not carry a frozen copy of its permissions. The user is resolved again on every request, so editing a role, changing which sites an account holds, or removing elevation takes effect on the next call rather than at the next login.

Disabling, deleting or revoking an account, or letting its Valid Until pass, drops any token it holds on the next call as well — the request is refused with invalid_session and the session is discarded. Disabling a user is therefore the containment control for REST as well as for the socket API, and both cut off at the same moment.

Applying the change still matters: role edits queue as pending changes, and the new rules reach the REST surface when you click Apply All (see Roles).

Auditing

Every REST API call is recorded to Request History with request_type: rest_api. The row captures the method, path, HTTP status, elapsed time, client IP, user, and grant/deny reason. Use this for security review and troubleshooting permission issues.

The recorded client IP is the connection's own peer address unless the peer is a configured trusted proxy, in which case the originating entry of the x-forwarded-for chain is used — read right to left, since proxies append. An x-forwarded-for sent by a client GEM is not fronting is ignored, so the audit row can't be authored by the caller. See Reverse proxy.

Denial reasons include:

ReasonDescription
malformed_bodyRequest body was not valid JSON or exceeded the 256KB size cap
missing_credentialUsername or password missing on /api/token
invalid_credentialUsername/password rejected
no_sitesUser has no sites assigned
no_rolesUser has no roles assigned
2fa_requiredFirst leg of a 2FA login — code emailed
2fa_mismatchSubmitted 2FA code did not match
2fa_no_email2FA enabled but user has no email address on file
invalid_sessionToken missing, unknown, expired, or held by an account that has since been disabled, deleted, revoked or fallen outside its validity window — including a session dropped because it became provably remote on a local-only role
remote_deniedLocal-only account (Allow Remote off on any role the holder has) requesting /api/token from a provably remote address
unauthorized/api/session_handoff called without a valid API session
role_deniedUser's role does not allow the requested API function
elevation_deniedNon-elevated command against an Elevated device, or run of an Elevated macro
scope_deniedNon-elevated command targeting a zone or device on no UI the user can access (see UI scope for commands)
unknown_macroNon-elevated macro request naming a macro that does not exist
elevated_tableNon-elevated read of a table outside the allowlist (see Read guards)
invalid_projectionNon-elevated query with a [column, alias] pair in _fields (see Read guards)
redacted_filterNon-elevated query filtering on a redacted column such as pin or token (see Read guards)
secure_filteredNon-elevated request for secure attribute rows, or one filtering on an attribute value — answered with an empty array
invalid_actionUnknown /api/control action
not_implementedWrite method against /api/data

Limits

  • Rows per query: 1000 by default, 10000 maximum — see Row limits.
  • Body size: requests larger than 256KB are rejected with malformed_body.
  • Token length: 32-character cryptographically random string.
  • Token lifetime: governed by cfg.api_token_life (milliseconds, default 1 hour). Tokens are evicted lazily on the next call after expiry.
  • Sessions: held in memory. Restarting GEM invalidates all tokens; clients must re-authenticate.

Disabling the API

The REST API can be disabled globally with the REST API switch on Server Settings (it takes effect after a restart), or by unchecking "Enable REST API" during Installation. When disabled, /api/token, /api/logout, /api/me, /api/session_handoff, /api/control/* and /api/data/* return {"error": "rest api disabled"} with HTTP 401.

Note that disabling the API also disables /api/session_handoff, which the cloud proxy uses to hand a remote user's verified login to the GEM UI. Remote users then sign in at GEM's own login screen instead, with a second 2FA code if they have 2FA on.

The two public probes are unaffected: /api/health and /api/version keep answering normally whether the API is enabled or not, so an uptime monitor does not have to be reconfigured when the API is switched off.

/api/sync is the peer-sync transport between paired controllers, is handled before this API, and is governed separately.

  • Roles — API permissions and rule syntax
  • Request History — Audit log for REST API calls
  • Web Services — Custom HTTP endpoints with user-defined handlers
  • Installation — Enabling/disabling the REST API at install time