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
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /api/token | credentials | Exchange username + password for a session token. |
POST | /api/logout | token | Revoke the current token. Always returns {"success": true}. |
GET | /api/me | token | Current identity: id, username, roles, sites, two_factor_auth, issued. |
GET | /api/health | none | Liveness probe. Always {"ok": true}. |
GET | /api/version | none | Controller version string. |
POST | /api/control/:action | token + permission | Run a command or a macro. |
GET | /api/data/:entity | token + permission | Read 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>
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.
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:
| Request | Result |
|---|---|
| Command that resolves to a device flagged Elevated (directly or through its zone) | 403 {"error": "unauthorized"} |
Macro flagged Elevated, including the {"macro_id": …} redirect shape | 403 {"error": "unauthorized"} |
| Macro that does not exist | 403 {"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:
| Parameter | Effect |
|---|---|
_instance=true | Merge 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=desc | Sort. Comma-separate for multiple keys. |
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 answers403. - Secure attribute values are never returned:
secure=falseis forced into the filter, surviving rows that are flagged secure or sensitively named come back with anullvalue, and an explicit?secure=truereturns an empty array. - Secure values merged in by
_instance=true(device passwords, camera credentials, integration keys) come backnull; 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
| Status | Meaning |
|---|---|
200 | Success — or an authentication failure on /api/token, or a query error (unknown entity, bad column) returned as {"error": …} in the body. |
400 | Malformed JSON body, body over 256 KB, or an unknown /api/control action. |
401 | Missing, unknown or expired token — or the REST API is switched off. |
403 | Authenticated, but the role denies the action, the entity is outside the non-elevated allowlist, or the target is flagged Elevated. |
405 | Non-POST request to /api/control/:action. |
501 | Write method against /api/data/:entity. |
500 | Unhandled 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
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.