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 productmatrix(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 requirerooms:read/addresses:readat the router: the handler checksrooms:operatewith the teacher's room rights,addresses:manageorallowed_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 404unknown_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_casekeys, 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 returnitems,page,page_sizeandtotal. 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:
| Code | When |
|---|---|
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:
- follow
job.updateandmatrix.changeon the live stream, or pollGET /tenants/{tenantID}/jobs/{jobID}; - the change's
resultbecomessuccess,unconfirmed,deniedorerror(Changes and results), withdetail(the error code) for the latter three; - the job's
resultcarries the connector'sPolicySetResult(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_reusedwhen it belongs to another operation.group_versionis the group'sversionfromGET …/address-groups/detail?policy_id=&group=, which also returns the caller's unfinished operations.- The operation's
stagefollows the connector's journal (prepared,update_sent,create_sent,created,member_sent,done), and itsresultthe change's. POST /tenants/{tenantID}/address-operations/{operationID}/retry {"group_version": "…"}resumes an interrupted operation: only its author, only whenretryable(a write had started and it is not done;409 not_retryableotherwise).
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;sharedandrooms(every present room, ordered like the rooms page):AllowedSitesListwithlist(sharedor the room code),room,building,group,exists,policy({policy_id, name, status, covers}),setup(ok,group_missing,policy_missing,policy_disabled,policy_not_covering, orunknownwhen the connector has not reported the lists),version,editableandreason,domains(SiteEntry{domain, object, owned, editable, reason}),can_edit(for the caller),busy_job_idandlast_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:
| Code | When |
|---|---|
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:
| Event | Payload | Meaning |
|---|---|---|
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.change | the change | A change finished. |
job.update | the job | A job's status or result changed. |
connector.status | the connector | A 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.