Sphere API
The Sphere 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 Sphere API reference, rendered
from entrosity-sphere.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/sphere/api/v1(the proxy strips/sphere). The connector API is under/sphere/api/connector/v1(Sphere connector protocol). - Authentication:
Authorization: Bearer <product token>, a five-minute token from Entrosity Hub for the productsphere(POST /api/platform/v1/auth/product-token {"product":"sphere"}with the Hub's session, Hub API). Sphere has no sign-in of its own; users, tenants and roles come from the Hub. While Sphere is in beta, the Hub issues its tokens to platform admins only. - Access rules: every route has a rule in
entrosity-sphere.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. Two routes check a second permission in the handler: a playback (kind: playback) also needsplayback:view, and a shared view also needsviews:manage. - 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. Other lists return all items. - Alarm log:
GET /tenants/{tenantID}/alarmsis newest first with keyset pagination:limit(1–500, default 100), filtersfrom,to,nvr_id,camera_id,ackedandcode(repeat for several, at most 20). When the page is full, the answer hasnext_before_atandnext_before_id: pass them asbefore_atandbefore_idfor the next page. - Rate limit: 20 requests per second per client IP, burst 60
(
SPHERE_API_RATE_PER_SECOND,SPHERE_API_RATE_BURST); 429 beyond. - Every response carries
X-Request-ID.
Errors
Errors are RFC 7807 application/problem+json with a stable code:
{
"type": "/problems/stream_limit",
"title": "Conflict",
"status": 409,
"code": "stream_limit",
"detail": "too many streams are open for this site or NVR; close some first",
"request_id": "…"
}
Validation errors have code: "validation" and fields with a message
per field. The conflicts you meet most:
| Code | When |
|---|---|
stream_limit | POST /streams: the connector's, or the NVR's main-stream or playback, limit is reached and no idle live stream can stop to make room (Limits). |
nvr_offline | POST /streams, PTZ, recording searches: the NVR is disconnected or not online (also while its connector is offline). POST /nvrs/{nvrID}/tune-live: the NVR is disconnected. |
camera_disabled | POST /streams: the camera is switched off. |
connector_offline | POST /nvrs/{nvrID}/test, POST /nvrs/{nvrID}/tune-live: the NVR's connector is not connected. |
nvr_address_taken | Adding or changing an NVR: the connector already drives an NVR at that host and port. |
connector_has_nvrs | Removing a connector that still drives NVRs. |
All codes: Error codes.
Watching video
Video does not go through the API: the API hands out a viewer session with a short-lived token, and the browser plays the stream from the media server over WebRTC (WHEP).
-
Open:
POST /tenants/{tenantID}/streamswithcamera_idand:- live:
kind: "live"(the default) andprofilesub(the default, for grids) ormain(full resolution); - playback:
kind: "playback",startandend(afterstart, at most 24 hours later,startnot in the future).
The answer (201) is a
StreamSession: itsid(the viewer session), the mediapath,whep_url, atokenandtoken_expires_at(five minutes), and the stream'sstateanderror. - live:
-
Play: send the WebRTC offer to
whep_url(WHEP) withAuthorization: Bearer <token>. The token is checked when the WHEP session opens, and only for that path. -
Keep alive:
POST /tenants/{tenantID}/streams/{streamID}/keepaliveevery 15 seconds. It answers the session again with the current state and a fresh token (use it if the player has to reconnect). A viewer without a keepalive for 45 seconds is dropped. -
Close:
DELETE /tenants/{tenantID}/streams/{streamID}(204). A live stream nobody watches keeps running for its idle grace period, 5 minutes for a sub stream and 2 minutes for a main stream (SPHERE_LIVE_IDLE_GRACE,SPHERE_LIVE_IDLE_GRACE_MAIN), so a viewer switching back to the camera joins it without a new start on the NVR; at a stream limit, the idle stream unwatched the longest stops to make room. A playback stops as soon as its viewer is gone, at the nextstreams.sweep(within 10 seconds), also when the viewer was dropped for missing keepalives. The web app closes its sessions when the page unloads (akeepaliverequest onpagehide), so a reload or a closed tab frees them at once.
state | Meaning |
|---|---|
starting | Wanted; the connector has not published it yet (also while its connector reconnects). |
live | The connector publishes it (from the moment its start job succeeds): play it. |
failed | The connector could not start it; error says why. The next viewer who opens it starts it again. |
stopped | Nobody wants it any more (closed, or the NVR was disconnected), or a playback reached its end. |
- A live stream (
t/<tenant>/live/<camera>/<main|sub>) is shared: the first viewer starts it on the connector, later viewers join it, and it is pulled from the NVR once whatever the number of viewers. A playback (t/<tenant>/pb/<session>) is the viewer's own. - A viewer session belongs to the user who opened it: another user's session answers 404.
- Changes of state also arrive as
stream.stateevents (below).
Asynchronous operations
Some calls start a job on the connector and return the job at once (202):
| Call | Job | Result |
|---|---|---|
POST /tenants/{tenantID}/nvrs/{nvrID}/test | sphere.nvr.test | {info, rtsp_reachable, warnings}; on success the NVR's details and cameras are updated. |
POST /tenants/{tenantID}/cameras/{cameraID}/recordings {from, to} (at most 7 days) | sphere.recordings.find | {segments: [{start, end, type, size_bytes}], truncated} |
POST /tenants/{tenantID}/cameras/{cameraID}/ptz {code, action, speed?, preset?} | sphere.ptz | none |
POST /tenants/{tenantID}/nvrs/{nvrID}/tune-live {keyframe_seconds?} | sphere.nvr.tune_live | NvrLiveTuneResult: {channels: [{channel, fps, gop_before, gop_after, changed, error, measured_ms}]} (measured_ms: the interval measured in the sub stream; a camera that keeps a longer one is changed: false) |
Poll GET /tenants/{tenantID}/jobs/{jobID} until status is
succeeded, failed, timeout or cancelled (or watch job.update);
result, error_code and error carry the outcome.
- PTZ: a movement (
Up,Down,Left,Right, the diagonals,ZoomTele,ZoomWide,FocusNear,FocusFar,IrisLarge,IrisSmall) runs fromaction: "start"untilaction: "stop"(hold-to-move);speedis 1–8.GotoPreset,SetPresetandClearPresetusestartonly, withpreset1–255. A command expires after 5 seconds, so a late one never moves the camera. Cameras the NVR does not report as PTZ are not refused; the command fails on the connector if the camera cannot do it. - NVR state: adding an NVR, connect and disconnect
(
POST /nvrs/{nvrID}/connect,…/disconnect), and, for a connected NVR, a new address or time zone and new credentials (PUT /nvrs/{nvrID}/credentials) each queue asphere.nvr.applyjob by themselves; a newer one replaces an older one still waiting.GET /tenants/{tenantID}/nvrs/{nvrID}/jobslists the NVR's last 50 jobs. - Optimise for live view:
tune-live(nvrs:manage, audited asnvr.tune_live) sets each camera's sub-stream keyframe interval on the NVR to at mostkeyframe_seconds(1–4, default 1; the body is optional) × its frame rate. Main streams are never changed, shorter intervals are kept. Per channel,fpsis the sub stream's frame rate,gop_beforeandgop_afterthe interval in frames,changedwhether the NVR took the new one (falsewithouterror: already short enough), anderrorwhy not (also when the NVR accepted a value it did not keep). 422validationfor anotherkeyframe_seconds, 409nvr_offlinewhen the NVR is disconnected, 409connector_offlinewhen its connector is offline (NVRs). - NVR passwords are write-only: they are accepted by
POST /nvrsandPUT /nvrs/{nvrID}/credentialsand never returned.
Live updates
The app receives live updates as server-sent events:
POST /auth/sse-token {"tenant_id": …}returns a token valid for 60 seconds, bound to the tenant and the session.GET /tenants/{tenantID}/stream?sse_token=<token>(or with the bearer token) streamstext/event-stream, with a keep-alive comment every 25 seconds.
| Event | Data |
|---|---|
alarm.new | An array of new alarms {id, occurred_at, code, action, channel, nvr_id, camera_id} (up to 50 per event). |
alarm.ack | {ids?, before?}: alarms were acknowledged, by id or up to a moment. |
nvr.status | {nvr_id, status, error?, changed?}; changed means the NVR's details or cameras changed (read them again). |
stream.state | {path, state, error?} |
connector.status | {connector_id, status} (online, offline, deleted) |
job.update | {id, connector_id, nvr_id?, type, status, error_code?, finished_at?}: a connector job changed state. |
Alarms and views
POST /tenants/{tenantID}/alarms/ackacknowledges alarms byids(at most 500), or every alarm up tobefore, or both; it answers{acked}(how many) and sendsalarm.ack. Acknowledging records who and when; alarms are never changed otherwise.- Saved views (
/tenants/{tenantID}/views):name,layout(the split by its tile count: 1, 4, 6, 8, 9, 16, 25, 36 or 64, where 6 and 8 are "one big + 5" and "one big + 7"),cells(a camera per cell, in order, at most 64;nullfor an empty cell),shared. A view is the user's own, or shared with the tenant (views:manageto save, change or delete). Another user's own views answer 404.
Connector installers
GET /tenants/{tenantID}/connector-release(nvrs:manage) answers the newest connector release ofSPHERE_CONNECTOR_UPDATE_CHANNEL, aConnectorRelease:{version, channel, sha256, size_bytes, notes, created_at, download_url, download_expires_at}.download_urlneeds no sign-in and works for one hour; every call makes a fresh one. 404 when no release is stored (or releases are off). Download connector uses it.- New enrollment tokens (
POST /tenants/{tenantID}/enrollment-tokens) carry the same kind of link indownload_urlwhen a release is stored, elseSPHERE_CONNECTOR_DOWNLOAD_URL(may be empty).
The installers themselves are served by the release API,
/api/releases/v1 (https://hub.entrosity.com/sphere/api/releases/v1),
outside /api/v1 and its sign-in:
| Method and path | Purpose |
|---|---|
POST /connector?version=&channel=¬es= | CI uploads a build: the body is the MSI (at most 64 MiB), Authorization: Bearer <SPHERE_RELEASE_TOKEN>, channel stable (default) or dev. 201 {id, version, channel, sha256, size_bytes, created_at}; 401 wrong token; 409 release_exists (the version is already published); 422 invalid version, channel or size; 503 releases_disabled (no SPHERE_RELEASE_SIGNING_KEY). |
GET /connector/{releaseID}/{file}?t=<link token> | Downloads the installer (application/x-msi, sphere-connector-<version>.msi). t is an expiry and an HMAC; a wrong or expired link answers 404. |
Setting up and rollout: Running Entrosity Sphere → Connector releases.
Sensitive actions
Deleting an enrollment token permanently
(POST /tenants/{tenantID}/enrollment-tokens/{tokenID}/delete) is for
global admins and needs a step_up_token: the Hub issues it for the
admin's password (POST /api/platform/v1/auth/step-up, product sphere,
Hub API).