Skip to main content

Matrix API

The Matrix web app is a plain client of a REST API under /api/v1. Everything the app does, you can do with the API. The full endpoint reference is the Matrix API reference, rendered from entrosity-matrix.backend/api/openapi.yaml, the spec the server's router and request validation (and the app's TypeScript types) are generated from.

Conventions​

  • Base URL: https://hub.entrosity.com/matrix/api/v1 (the proxy strips /matrix). The connector API is under /matrix/api/connector/v1 (Matrix connector protocol).
  • Authentication: Authorization: Bearer <product token>, a five-minute token from Entrosity Hub for the product matrix (POST /api/platform/v1/auth/product-token {"product":"matrix"} with the Hub's session, Hub API). Matrix has no sign-in of its own; users, tenants and roles come from the Hub. While Matrix is in beta, the Hub issues its tokens to platform admins only.
  • Access rules: every route has a rule in entrosity-matrix.backend/internal/http/portal/access.go: public, signed in, global admin, or a tenant permission (Roles and permissions). Tenant users may only call /tenants/{their tenant id}/…; any other tenant answers 404. A missing permission answers 403. The change routes (room status, bulk status, cancelling a schedule, address update, create and retry, changing an allowed-sites list) only require rooms:read / addresses:read at the router: the handler checks rooms:operate with the teacher's room rights, addresses:manage or allowed_sites:manage, so that a refused attempt is recorded in the history before it answers 403.
  • Teachers see only their rooms: for a teacher, GET /rooms, the allowed sites, the address groups, the schedules, the history and the event stream leave out every room not granted to them (and the firewalls without any of their rooms); a job, bulk action, address group or operation of such a room, or the allowed sites or address groups of such a firewall, answer 404, and a change of such a room answers 404 unknown_room (recorded as refused), exactly like a room that does not exist. Viewers and admins see every room (entrosity-matrix.backend/internal/visibility).
  • JSON bodies with snake_case keys, at most 1 MB. Request schemas refuse unknown fields (422).
  • Lists: the audit logs and the admin tenant list take ?page=&page_size= (default 50, maximum 200) and return items, page, page_size and total. The history uses a cursor (History). Other lists return all items.
  • Rate limits: 20 requests per second per client IP, burst 60 (MATRIX_API_RATE_PER_SECOND, MATRIX_API_RATE_BURST), 429 without a code beyond. Changes also count against 10 per user and 30 per tenant per minute (429 rate_limited, recorded as refused).
  • Every response carries X-Request-ID.

Errors​

Errors are RFC 7807 application/problem+json with a stable code:

{
"type": "/problems/snapshot_stale",
"title": "Conflict",
"status": 409,
"code": "snapshot_stale",
"detail": "the firewall's state is out of date; wait for the next report",
"request_id": "…"
}

Validation errors have code: "validation" and fields with a message per field (for example a policy_pattern without (?P<room>…), a reenable_at outside 5 minutes to 7 days, reenable_at with enable). The ones you meet most:

CodeWhen
unknown_room (404)A teacher switches a room not granted to them, or one that does not exist (a bulk change: any of the rooms; nothing is sent).
forbidden (403)The role lacks the permission (a viewer switching, a teacher changing an address).
snapshot_stale (409)The firewall's rooms are stale (Rooms → Stale data).
connector_offline (409)The firewall's connector is not connected.
writes_disabled (409)The firewall's writes_enabled (rooms), address_writes_enabled (addresses) or site_writes_enabled (allowed sites) is off.
busy (409)A change of the room (or an address change of the firewall) is still running.
unknown_room (409)The firewall does not report the room.
conflict (409)Addresses: the group changed since group_version, or the IP since expected_ip. Allowed sites: the list changed since list_version.
rate_limited (429)Change limits.

All codes: Error codes.

Firewalls​

POST /tenants/{tenantID}/firewalls adds a firewall behind one of the tenant's connectors; PATCH changes it and DELETE removes it. Every change sends the configuration to the connector (matrix.firewall.apply, protocol). writes_enabled, address_writes_enabled and site_writes_enabled default to false, verify_tls to true, port to 443, vdom to root, report_interval_s to 30. A Firewall also has sites_supported (read-only: its connector announced the sites capability). services_enabled (the removed Entrosity services) is no longer part of the API: a request that sends it is refused (422).

The FortiGate token is write-only: token in the create request, or PUT /tenants/{tenantID}/firewalls/{firewallID}/token {"token": "…"} (204). It is never returned; storing it bumps credentials_version and re-applies the firewall.

POST …/check (202, a Job) runs the read-only check; its result is a CheckResult (version, direction_ok, rooms, groups, members, warnings, guard_state, policy_names, and sites from connectors that support allowed sites). POST …/refresh {"scope": "rooms" | "addresses" | "all"} reads the firewall now.

Switching rooms​

GET /tenants/{tenantID}/rooms[?firewall_id=] returns firewalls (with stale, updated_at, error, error_code, simulated, guard_state, writes_enabled, site_writes_enabled, sites_supported, shared_allowed_sites: the domains of the list for all rooms) and rooms (with status, present, can_manage for the caller, busy_job_id, schedule, last_change and allowed_sites: the domains of the room's own list, 0 when it has none).

POST /tenants/{tenantID}/firewalls/{firewallID}/rooms/SB1-102/status
{"status": "disable", "reenable_at": "2026-10-01T14:00:00Z"}

answers 202 with {change, job}: the history row (result pending) and the matrix.policy.set job. The outcome arrives asynchronously:

  1. follow job.update and matrix.change on the live stream, or poll GET /tenants/{tenantID}/jobs/{jobID};
  2. the change's result becomes success, unconfirmed, denied or error (Changes and results), with detail (the error code) for the latter three;
  3. the job's result carries the connector's PolicySetResult (previous, status, wrote, confirmed).

Rules: reenable_at only with disable, 5 minutes to 7 days ahead; it creates a re-enable schedule. Any newer change of the room cancels its pending schedule. A refused request still writes a denied change.

Bulk: POST …/firewalls/{firewallID}/bulk-status {"status", "rooms": […] | "building": "SB1", "reenable_at"?} answers {bulk: {id, items: [{room, change_id, job_id, error?}]}}; rooms with a running change are skipped (error: "busy"). GET /tenants/{tenantID}/bulk-actions/{bulkID} shows each room's outcome.

Schedules: GET /tenants/{tenantID}/schedules[?status=], POST …/schedules/{scheduleID}/cancel (only pending; 409 schedule_not_pending otherwise).

Address operations​

POST /tenants/{tenantID}/firewalls/{firewallID}/addresses/update
{"policy_id": 102, "group": "SB1-102-Students address", "name": "pc-102-01.coding.local",
"ip": "192.0.2.21", "expected_ip": "192.0.2.11", "group_version": "<64 hex>",
"request_id": "<uuid>"}

…/addresses/create takes the same fields without expected_ip. Both need addresses:manage and answer 202 with {operation, job, change}.

  • request_id (a UUID the client generates) makes the request idempotent: sending it again returns the same operation; request_id_reused when it belongs to another operation.
  • group_version is the group's version from GET …/address-groups/detail?policy_id=&group=, which also returns the caller's unfinished operations.
  • The operation's stage follows the connector's journal (prepared, update_sent, create_sent, created, member_sent, done), and its result the change's.
  • POST /tenants/{tenantID}/address-operations/{operationID}/retry {"group_version": "…"} resumes an interrupted operation: only its author, only when retryable (a write had started and it is not done; 409 not_retryable otherwise).

Allowed sites​

GET /tenants/{tenantID}/firewalls/{firewallID}/allowed-sites

(rooms:read) returns an AllowedSitesView:

  • firewall: stale, updated_at (when the lists were last read), connector_online, enabled, guard_state, site_writes_enabled, sites_supported;
  • shared and rooms (every present room, ordered like the rooms page): AllowedSitesList with list (shared or the room code), room, building, group, exists, policy ({policy_id, name, status, covers}), setup (ok, group_missing, policy_missing, policy_disabled, policy_not_covering, or unknown when the connector has not reported the lists), version, editable and reason, domains (SiteEntry {domain, object, owned, editable, reason}), can_edit (for the caller), busy_job_id and last_change;
  • can_setup: the caller may get the setup CLI.
PUT /tenants/{tenantID}/firewalls/{firewallID}/allowed-sites/SB1-102
{"domains": ["classroom.google.com", "*.moodle.example.org"], "list_version": "<64 hex>"}

{list} is shared or a room code. domains is the full desired set of Matrix-managed domains (lower-cased, sorted, duplicates removed; at most 200); list_version is the list's version. The shared list needs allowed_sites:manage (tenant admins); a room's list also accepts an teacher with rooms:operate and a room right for it. It answers 202 with {change, job}: the sites change and the matrix.sites.set job, whose outcome arrives like a room switch's (success, unconfirmed, denied, error; the job's result is a SitesSetResult with added, removed, wrote and confirmed). Refusals, each recorded as a denied change:

CodeWhen
forbidden (403)The shared list by a non-admin, or a viewer.
unknown_room (404)A teacher without the room's right.
group_read_only (403)Matrix may not change the group on the FortiGate (the detail says why).
unknown_room (409)The room is not a present room of the firewall.
invalid_domain (422)A domain is not valid (fields.domains says which), or more than 200.
connector_offline (409)The connector is offline.
connector_outdated (409)The connector does not support allowed sites (no sites capability).
snapshot_stale (409)The firewall's data is stale, or the lists were never reported.
writes_disabled (409)The firewall's site_writes_enabled is off.
sites_not_set_up (409)The list's group or its ACCEPT policy is missing.
conflict (409)list_version is not the list's current version.
busy (409)Another change of this list is still running.
rate_limited (429)The change limits (a save counts like a room switch).
GET /tenants/{tenantID}/firewalls/{firewallID}/allowed-sites/setup-cli?rooms=SB1-102,SB1-108

(allowed_sites:manage) returns {cli, lists, warnings}: the FortiOS CLI that creates what is missing of the shared list and the named rooms' lists (empty when nothing is missing), the lists it sets up (shared, room codes), and the problems it cannot fix (Setting up the FortiGate).

Room rights​

GET /tenants/{tenantID}/room-grants[?user_id=] (tenant admins see everyone's, others their own); PUT /tenants/{tenantID}/users/{userID}/room-grants {"firewall_id", "rooms": […]} replaces a teacher's rooms on one firewall (grants:manage; 422 not_teacher for anyone who is not a teacher of the tenant). Rooms to add must be reported by the firewall; removing is always possible. Recorded as a grants change.

History​

GET /tenants/{tenantID}/history returns the newest changes first, with the filters from, to, user_id, room (part of a room code, case-insensitive), kind (policy, address_update, address_create, grants, schedule, bulk, sites, and services for old entries) and firewall_id, and limit (1–200, default 50). When there are more, the page has next_cursor: pass it as cursor. Each change has source (matrix, or stop-internet for imported rows) and metadata.steps (the write-time authorizations and address and sites stages).

sites changes have room (empty for the shared list), group_name, previous and desired (summaries such as 3 domains) and metadata {list, domains, added, removed, previous_domains} (a refusal: {list, requested}). services changes are read-only history from before the Entrosity services were removed: the former switch (previous / desired enable or disable) or a system sync of the group; no new ones are made.

Live updates​

GET /tenants/{tenantID}/stream is a text/event-stream. Browsers authenticate with ?sse_token= from POST /auth/sse-token {"tenant_id": "…"} (valid 60 seconds; keeps the access token out of URLs). Events:

EventPayloadMeaning
matrix.rooms{firewall_id}Reload the rooms (a report arrived, a room changed, the firewall became stale or current).
matrix.addresses{firewall_id}Reload the address groups.
matrix.sites{firewall_id}Reload the allowed sites (a report with new lists, a change sent or finished).
matrix.changethe changeA change finished.
job.updatethe jobA job's status or result changed.
connector.statusthe connectorA connector came online or went offline.

Connector installers​

GET /tenants/{tenantID}/connector-release (tenant admins) returns the newest release of the update channel with a download link valid one hour, or 404 when none is stored. Global admins list every stored release under GET /admin/connector-releases. CI uploads releases to /api/releases/v1/connector (Running Entrosity Matrix → Connector releases).

Sensitive actions​

Deleting an enrollment token permanently (global admins) needs a step-up token from the Hub for the caller's password (POST /api/platform/v1/auth/step-up, product matrix), sent as step_up_token to POST /tenants/{tenantID}/enrollment-tokens/{tokenID}/delete. FortiGate tokens are never returned by any endpoint, and every fetch by a connector is audited (firewall.credentials_fetch). Every accepted allowed-sites change is audited as allowed_sites.set (resource the firewall).

Internal API (Entrosity Axis)​

Entrosity Axis's screen wall reads rooms on a separate listener, MATRIX_INTERNAL_ADDR (:8086), that is never routed publicly. Every request needs Authorization: Bearer <MATRIX_AXIS_TOKEN> (401 otherwise; the listener is off without the token).

GET /internal/v1/screen-rooms?tenant=<id>&all=true lists every room of the tenant's enabled firewalls; ?tenant=<id>&user=<id> only the rooms granted to that user as an active teacher of an active tenant (none for other roles). Ids are Hub ids. Each room has room, building, building_name, firewall_id, firewall_name and computers: the names of the /32 members of the room's address groups from the firewall's last report. The contract is proto/matrix.ScreenRooms in entrosity-shared-go.