Skip to main content

REST API

GEM exposes a small, fixed REST surface for external systems: authenticate, read configuration and live state, and dispatch commands or macros. It is mounted under /api on the controller and is gated by the same role permissions as the socket surface.

Turn it on in the admin under System → Server → REST API (the change takes effect after a restart) — see Server Settings. A fresh install enables it by default.

Open Server Settings

When the REST API is switched off, every endpoint below except /api/health and /api/version answers 401 with {"error": "rest api disabled"}. (/api/sync is the peer-sync channel between paired controllers; it is handled separately and is not part of this API.)

Endpoints

MethodPathAuthPurpose
POST/api/tokencredentialsExchange username + password for a session token.
POST/api/logouttokenRevoke the current token. Always returns {"success": true}.
GET/api/metokenCurrent identity: id, username, roles, sites, two_factor_auth, issued.
GET/api/healthnoneLiveness probe. Always {"ok": true}.
GET/api/versionnoneController version string.
POST/api/control/:actiontoken + permissionRun a command or a macro.
GET/api/data/:entitytoken + permissionRead rows from one entity.

There is no write path over REST. POST, PATCH, PUT and DELETE against /api/data/:entity return 501 with {"error": "not implemented"} — configuration writes go over the WebSocket API, and anything an outside system needs to change should be wrapped in a macro and invoked through /api/control/macro.

Authentication

curl -X POST https://<host>/api/token \
-H "Content-Type: application/json" \
-d '{"username": "integrator", "password": "<password>"}'
{"token": "…32-character token…", "expires": 3600000, "sites": [{"id": 1, "name": "main"}]}

expires is the token lifetime in milliseconds (default one hour, set by api_token_life in gem.json), not an absolute expiry time. Sessions are held in memory, so restarting the controller invalidates every issued token.

Send the token on subsequent requests with either header — both are accepted:

Authorization: Bearer <token>
gem-api-token: <token>
Login failures return HTTP 200

For compatibility with older clients, /api/token reports failure as HTTP 200 with an {"error": …, "message": …} envelope — not a 4xx. A client must inspect the body, not just the status. Possible messages: missing credential, invalid credential, no sites found, invalid roles, malformed body, two factor fail, 2FA is enabled but user has no email address. /api/control and /api/data do use proper status codes.

The account must have at least one role and at least one site assigned, or the token request is refused (no sites found / invalid roles). Create the integration account under Users and give it a role that grants only the actions it needs.

A token keeps the permissions it was issued with

Role rules, site list and elevated status are captured when the token is minted. Changing the role — or disabling the account — afterwards does not revoke or re-evaluate a token that is already out; it keeps working until it expires or the controller restarts. Plan around a short api_token_life for integrations whose access may need to be pulled quickly.

Two-factor accounts

If the user has two-factor authentication enabled, the first /api/token call emails a 6-character code and answers:

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

Resubmit the same credentials plus two_factor_token to complete the login. The comparison is case-insensitive and the code is cleared once used. ttl_seconds is the window the code is accepted in as well as the prompt hint — a code submitted after it lapses is refused as two factor fail, and submitting the login again issues a fresh one. Set it with api_2fa_ttl_seconds (default 300). The session lifetime is separate, governed by api_token_life.

The same gate now covers the socket login used by the web UI, wall panels and the mobile app, so enabling two-factor on a user affects every sign-in surface rather than just this endpoint. PIN logins are exempt.

Commands — POST /api/control/:action

POST only. Any other method returns 405 with an Allow: POST header. Two actions exist — command and macro; anything else returns 400 {"error": "invalid action"}.

curl -X POST https://<host>/api/control/command \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"zone_id": 12, "command": "on"}'

The body is the standard command payload: a target (zone_id, zone by name, device_id, or device by name), the verb in command, and any parameters in args.

{"zone_id": 12, "command": "level", "args": {"level": 75}}
{"zone_id": 31, "command": "setpoint", "args": {"setpoint": 72, "mode": "cool"}}
{"zone": "front_gate", "command": "open"}

A one-key shorthand is also accepted — {"zone_id": 12, "level": 75} is rewritten to the level command with args.level = 75. It only applies when exactly one verb key accompanies the zone, so spell the command out whenever you pass more than one value.

Macros run by id or name:

curl -X POST https://<host>/api/control/macro \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"macro_id": 12}'

{"macro_id": 12} sent to /api/control/command is honoured as a macro redirect and is gated as a macro.

Permissions and elevated targets

Calling these endpoints at all requires the command / macro action on the caller's role — see RBAC and Roles. A caller without it gets 403.

That permission does not override the per-entity Elevated flag. A session is elevated only when one of the user's roles has Elevated switched on; for everyone else:

RequestResult
Command that resolves to a device flagged Elevated (directly or through its zone)403 {"error": "unauthorized"}
Macro flagged Elevated, including the {"macro_id": …} redirect shape403 {"error": "unauthorized"}
Macro that does not exist403 {"error": "unknown macro"}

Nothing is dispatched when the gate refuses — the command never reaches the driver, and the same answer comes back over the socket surface. A per-zone action override that re-targets the command at different hardware is re-checked at the moment it re-dispatches, so a benign-looking zone cannot be used to reach an elevated device.

Data — GET /api/data/:entity

Read-only. :entity is the entity name (zone, device, attribute, macro, command, subsystem, …). Query-string parameters become the filter, and the caller needs the query permission.

# every zone in subsystem 11
curl "https://<host>/api/data/zone?subsystem_id=11" -H "Authorization: Bearer <token>"

# one device, with live state merged in
curl "https://<host>/api/data/device?id=42&_instance=true" -H "Authorization: Bearer <token>"

Useful modifiers, all expressible in the query string:

ParameterEffect
_instance=trueMerge each row with the controller's live in-memory object, so current state and attribute values (level, state, ip_address, …) come back alongside the stored columns.
_limit=<n>Row cap. Default 1000, maximum 10000; 0, negative or non-numeric falls back to the default.
_offset=<n>Skip rows — page a large result with _limit + _offset.
_order=name / _order=name=descSort. Comma-separate for multiple keys.
Numeric-looking values are sent as numbers

Any parameter that parses as a number is converted before the filter is built, so ?address=007 looks for 7, not the text "007". Filter on a different column, or on the exact stored value, when leading zeros matter.

What a non-elevated token can read

The query permission opens the read surface, not the whole database. For a non-elevated session:

  • Only an allowlisted set of entities is readable — attribute, av_source, av_zone, channel, channel_provider, command, device, macro, macro_step, subsystem, ui, ui_control, ui_macro, ui_page_ui, ui_page_widget, ui_theme, ui_widget, ui_zone, ui_zone_group_ui, zone. Everything else answers 403.
  • Secure attribute values are never returned: secure=false is forced into the filter, surviving rows that are flagged secure or sensitively named come back with a null value, and an explicit ?secure=true returns an empty array.
  • Secure values merged in by _instance=true (device passwords, camera credentials, integration keys) come back null; the rest of the merge is untouched.

Secret-bearing columns — hash, salt, pin, rfid, duress_pin, token, private_key, api_key, secret, password, bearer_token, credential_hash, and any string column ending in _token, _secret, _password or _hash — are blanked for every caller, elevated or not.

The full table of guards, denial reasons and limits is in REST API (Admin Reference).

Errors

StatusMeaning
200Success — or an authentication failure on /api/token, or a query error (unknown entity, bad column) returned as {"error": …} in the body.
400Malformed JSON body, body over 256 KB, or an unknown /api/control action.
401Missing, unknown or expired token — or the REST API is switched off.
403Authenticated, but the role denies the action, the entity is outside the non-elevated allowlist, or the target is flagged Elevated.
405Non-POST request to /api/control/:action.
501Write method against /api/data/:entity.
500Unhandled server error.

Because several failures arrive as HTTP 200 with an error body, a robust client checks for an error property on every response.

Auditing

Every REST call is recorded with a request type of rest_api, capturing the method, path, status, elapsed time, client IP, user and the grant or denial reason. Review it under Request History when a call is refused and the reason is not obvious.

Open Request History

The WebSocket API is the richer surface

REST covers reads, commands and macros. For real-time state, subscriptions, attribute writes and configuration changes, use the WebSocket API — both are gated by the same role actions. For endpoints with your own logic and payload shape, write a Web Service instead.