What the Endpoint device app — the downloadable macOS agent — sends once a
device is enrolled, how each report is stored, and how to get it back out.
This is not the Chrome proctoring extension. The proctoring product has its
own extension, its own API and its own docs. Everything below is about the
device-monitoring product only. Enrollment and credentials — QR enrollment,
POST /endpoint/register, API keys, the endpoint.registered webhook — are
covered by the Partner API doc; this doc starts
where that one ends, at an enrolled device holding a device_token.
Enrolled device (see the Partner API doc for how it got here)
→ wss://<server>/cable (ActionCable WebSocket)
X-Device-Token: <device_token> (preferred auth)
→ subscribe {"channel":"EndpointChannel",...}
← welcome (effective config: blacklist, whitelist, work_hours)
→ report frames: screenshot, process_list, app_change, app_violation,
browser_url, motion_detected, no_motion, monitoring_status
stored as endpoint_events / endpoint_violations rows + screenshot files
← violation_acknowledged / app_list_update
Operator, any time
→ /endpoint_console (dashboard, devices, screenshots)
Partner backend, any time
→ GET /partner/api/v1/endpoints (enrollment state)
→ GET /partner/api/v1/endpoints/:id/events (telemetry pull)
→ GET /partner/api/v1/endpoints/:id/violations (telemetry pull)
→ GET /partner/api/v1/endpoints/:id/events/:event_id/screenshot
The device opens an ActionCable WebSocket to wss://<server>/cable and
authenticates the handshake with a request header:
X-Device-Token (preferred) — the device_token minted byPOST /endpoint/register or POST /endpoint/login. An unknown token isX-User-Id (legacy) — the scanned QR enrollment token, which the shipped[DEPRECATION] log line, and the path will be removed once client 1.7 sendsX-Device-Token (#166).| Handshake | Result |
|---|---|
X-Device-Token matching a device |
accepted; device marked online |
X-Device-Token matching nothing |
rejected |
X-User-Id matching an enrolled, non-disabled device |
accepted, deprecation-logged |
X-User-Id matching nothing, or a disabled device |
rejected |
| neither header | rejected |
A device is marked online at connect and offline at disconnect.
Subscribe with the identifier
{"channel":"EndpointChannel","user_id":"<id>"}. Identity comes from the
authenticated connection, never from the identifier — the user_id field is
carried for wire compatibility with the shipped client.
On subscribe the server transmits a welcome config frame — the device's
effective configuration, org settings merged under per-device overrides
(device values win):
{
"type": "welcome",
"message": "Connected to Endpoint channel",
"blacklist": ["Discord", "Slack"],
"whitelist": [],
"work_hours": {
"enabled": true,
"start_time": "9:00 AM",
"end_time": "5:00 PM",
"work_days": [1, 2, 3, 4, 5]
}
}
Reports are ActionCable message commands whose data field is a
JSON-encoded string (not an object) carrying {"action":"report","data":{...}}:
{
"command": "message",
"identifier": "{\"channel\":\"EndpointChannel\",\"user_id\":\"<id>\"}",
"data": "{\"action\":\"report\",\"data\":{\"type\":\"app_change\",\"from_app\":\"Safari\",\"to_app\":\"Slack\",\"timestamp\":\"2026-08-28T12:00:00Z\"}}"
}
The inner data object's type selects the telemetry type (section 3).
The device may also send {"action":"update_work_hours","work_hours":{...}}
(same string-encoded framing). Only the keys enabled, start_time,
end_time and work_days are accepted; they merge into the device's
per-device override, which then wins over org settings in effective_config.
| Frame | Payload | When |
|---|---|---|
violation_acknowledged |
{"type":"violation_acknowledged","violation_id":42,"timestamp":"..."} |
echoed to the reporting device after each stored app_violation |
app_list_update |
{"type":"app_list_update","blacklist":[...],"whitelist":[...]} |
defined, but nothing in the server triggers it today — config changes reach a device via the welcome frame at its next connect |
| Type | Cadence | Payload fields | Stored as |
|---|---|---|---|
screenshot |
every 600 s while monitoring | timestamp, image_data, display_width, display_height |
endpoint_events row + bytes in endpoint_screenshots |
process_list |
every 600 s | timestamp, processes[], total_count |
endpoint_events row |
app_change |
on focus change | from_app, to_app, timestamp |
endpoint_events row |
app_violation |
on a blacklisted app | app_name, bundle_id, timestamp |
endpoint_violations row |
browser_url |
on browser URL change | url, browser, timestamp |
endpoint_events row |
motion_detected |
60 s check cycle | timestamp |
no row — updates last-activity only |
no_motion |
after >900 s without motion | duration, timestamp |
endpoint_events row |
monitoring_status |
on start/stop | status, timestamp |
device status flip + endpoint_events row |
Unknown types are logged server-side and dropped without error. Every report,
of any type, refreshes the device's last_seen_at.
Sent every 600 seconds while monitoring is active. image_data is a base64
JPEG of the main display at 0.5 compression quality. Capture (both screenshots
and the motion check below) pauses while a video-conferencing app is running
and resumes when it exits.
{
"type": "screenshot",
"timestamp": "2026-08-28T12:00:00Z",
"image_data": "<base64 JPEG>",
"display_width": 2560,
"display_height": 1440
}
Stored as an endpoint_events row whose event_data holds the display
dimensions and file_size_bytes, plus an endpoint_screenshots row holding
the decoded JPEG bytes themselves. The bytes go to the primary database — not
to the ingesting pod's local disk — so any web pod can serve them (#196; the
app runs multiple dynos with separate ephemeral filesystems). A frame whose
decoded payload exceeds 10 MB, or does not decode at all, is dropped with a
server-side warning and no rows.
Sent every 600 seconds: every running application with a localized name.
{
"type": "process_list",
"timestamp": "2026-08-28T12:00:00Z",
"processes": [
{
"name": "Safari",
"bundle_id": "com.apple.Safari",
"pid": 4242,
"is_active": true,
"is_hidden": false,
"is_terminated": false
}
],
"total_count": 1
}
Sent when application focus changes (observed via workspace notifications,
with a 30-second fallback poll).
{
"type": "app_change",
"from_app": "Safari",
"to_app": "Slack",
"timestamp": "2026-08-28T12:00:00Z"
}
Sent when a blacklisted app becomes active. Stored as an
endpoint_violations row (severity medium); the server echoes
violation_acknowledged back to the device.
{
"type": "app_violation",
"app_name": "Discord",
"bundle_id": "com.hnc.Discord",
"timestamp": "2026-08-28T12:00:00Z"
}
Sent when the active browser's URL changes.
{
"type": "browser_url",
"url": "https://example.com/page",
"browser": "Safari",
"timestamp": "2026-08-28T12:00:00Z"
}
The client hashes a screen capture every 60 seconds; a changed hash means
motion. Updates the device's last-activity timestamp only — no row is
stored.
{
"type": "motion_detected",
"timestamp": "2026-08-28T12:00:00Z"
}
Sent when the screen has not changed for more than 900 seconds. duration is
the elapsed seconds, as an integer.
{
"type": "no_motion",
"duration": 947,
"timestamp": "2026-08-28T12:00:00Z"
}
Sent when the user starts or stops monitoring. "active" flips the device
online, anything else flips it offline (a disabled device is never
touched); an endpoint_events row is stored either way.
{
"type": "monitoring_status",
"status": "active",
"timestamp": "2026-08-28T12:00:00Z"
}
Three tables plus a file store:
| Where | What |
|---|---|
endpoint_devices |
one row per device: hostname, serial_number, os_version, app_version, status (registered | online | offline | disabled), last_seen_at, last_ip, per-device config overrides. serial_number is the hardware serial (IOKit IOPlatformSerialNumber) reported by client 1.7+ at registration/login; it is null for devices registered by client <= 1.6 until they re-register with 1.7, and a serial-less payload never clears a previously captured value. last_ip is the source IP of the most recent registration/login; the partner API derives an approximate location from it at read time via a local GeoIP lookup (country-level with the default DB-IP database, city-level if a City MMDB is configured through GEOIP_DB_PATH; null for private IPs or when unresolvable — see the Partner API doc). IP Geolocation by DB-IP |
endpoint_events |
one row per stored report (screenshot, process_list, app_change, browser_url, no_motion, monitoring_status) |
endpoint_violations |
one row per app_violation |
endpoint_screenshots |
one row per screenshot event: the JPEG bytes, content_type, byte_size. Bytes live in the primary database so every web pod can serve them; nothing is written to pod-local disk (#196) |
If the org configures S3 in the console, Endpoint::UploadScreenshotJob
moves each screenshot's bytes to that bucket under
<prefix>endpoint/screenshots/<device_id>/<file> and then destroys the
database blob — the only deletion anywhere in the pipeline. The console
still serves offloaded screenshots by reading them back from that bucket.
Retention is currently indefinite; this is not a contractual guarantee — a
retention policy is tracked in #167. Until it lands, note the growth math:
a monitored device produces one screenshot every 600 s at roughly 500 KB, so
about 500 KB of database growth per device per 10 minutes while
monitoring is active. Orgs that configure S3 offload move that growth into
their own bucket almost immediately; without offload it accrues in the
primary database until #167's policy prunes it.
Screenshots recorded before the database migration (#196) were files on one
web pod's ephemeral disk. Any such file that survived is still served while
it exists; once it is gone the event reports available: false and its
bytes are unrecoverable.
The Endpoint console at /endpoint_console (session login) is the primary
surface:
GET /endpoint_console/devices/:id/screenshot/:event_idThe programmatic surface is the Partner API: GET /partner/api/v1/endpoints
plus the endpoint.registered webhook cover enrollment state — including each
device's last_ip and the approximate IP-derived location, which live on
the device row there, not on individual events (documented in the
Partner API doc) — and the telemetry pull
routes below cover everything a device has reported (issue #165). All of them
take the same Authorization: Bearer <api key> header, share the same
per-key 60 requests/minute throttle, and are structurally scoped: another
partner's device or event answers a 404 {"error": {...}} envelope whatever
parameters arrive.
GET /partner/api/v1/endpoints/:id/events
Authorization: Bearer <api key>
Filters and pagination, all optional query parameters:
| Parameter | Meaning |
|---|---|
event_type |
one of the telemetry catalogue's stored types (screenshot, process_list, app_change, browser_url, no_motion, monitoring_status) |
occurred_from / occurred_to |
ISO8601 bounds on occurred_at, each side independent; a malformed timestamp is a 400 invalid_parameter envelope |
page / per_page |
page number (default 1) and page size (default 100, max 500) |
Events come in ascending id order — stable across requests, so pages never
shuffle. total_count is the filtered total; keep fetching pages until
page * per_page >= total_count.
{
"events": [
{
"id": 91,
"event_type": "screenshot",
"occurred_at": "2026-08-28T12:00:00Z",
"data": {"display_width": 2560, "display_height": 1440, "file_size_bytes": 48213},
"screenshot": {
"available": true,
"url": "/partner/api/v1/endpoints/42/events/91/screenshot"
}
},
{
"id": 92,
"event_type": "app_change",
"occurred_at": "2026-08-28T12:00:30Z",
"data": {"from_app": "Safari", "to_app": "Slack"}
}
],
"page": 1,
"per_page": 100,
"total_count": 2
}
data is the stored event_data for the type, per the telemetry catalogue
above. Screenshot events additionally carry a screenshot availability
object — never a server filesystem path:
screenshot |
Meaning |
|---|---|
{"available": true, "url": "/partner/api/v1/..."} |
the bytes are in the server's database; GET that path (with your API key) for them |
{"available": true, "url": "s3://bucket/key"} |
offloaded to your configured S3 bucket — the bytes are already yours; read them there |
{"available": false} |
no bytes anywhere (never captured, or a pre-#196 pod-local file is gone) |
available: true with an API url is a serving guarantee, not a guess: the
list applies exactly the checks the bytes route applies, so a pull of a
listed-available screenshot cannot 404 (#196's secondary defect).
GET /partner/api/v1/endpoints/:id/violations
Authorization: Bearer <api key>
Same pagination contract (page, per_page, total_count, ascending id
order). One row per app_violation report:
{
"violations": [
{
"id": 7,
"violation_type": "blacklisted_app",
"app_name": "Discord",
"bundle_id": "com.hnc.Discord",
"severity": "medium",
"occurred_at": "2026-08-28T12:00:00Z",
"acknowledged": true,
"acknowledged_at": "2026-08-28T12:00:01Z",
"reviewed": false,
"recorded_at": "2026-08-28T12:00:01Z"
}
],
"page": 1,
"per_page": 100,
"total_count": 1
}
GET /partner/api/v1/endpoints/:id/events/:event_id/screenshot
Authorization: Bearer <api key>
| Status | Meaning |
|---|---|
| 200 | the JPEG bytes (Content-Type: image/jpeg), served from the database until offload |
| 404 | screenshot_not_found envelope — the event has no screenshot bytes anywhere (routing/scoping misses are a not_found envelope) |
| 410 | screenshot_offloaded envelope — the file was uploaded to your configured S3 bucket and deleted locally; the s3:// URL is in the message and in error.details.url. The bytes are already in your bucket — read them there, this route will not proxy them |
The 410 is deliberate: once Endpoint::UploadScreenshotJob has offloaded a
screenshot to the partner's own bucket, the durable copy is the partner's,
and re-serving it through this API would just proxy their own storage back
at them.
Screenshot bytes that have not been offloaded live in the primary database
and survive restarts and are served from every web pod — the pre-#196
failure mode, where the bytes existed only on the one pod that held the
device's WebSocket, is gone. Growth is bounded by the retention policy
tracked in #167 (about 500 KB per device per 10 minutes of monitoring until
then); configure S3 offload to own the durable copies immediately.
One honest limitation: devices enrolled through the Partner API currently
deliver enrollment state only. The shipped 1.6.0 client authenticates the
WebSocket with its enrollment token as X-User-Id, and partner-owned device
rows deliberately carry no such value — so their connections are rejected,
no telemetry is stored, and their status stays registered (it never flips
online/offline). This resolves when client 1.7 authenticates with
X-Device-Token (#166).
data.data from the{"action":"report","type":"screenshot",...} frame ismotion_detected stores no row. It only refreshes the device'sendpoint_events for it. Absence ofno_motion).endpoint_screenshots blob is destroyed; theendpoint_events row keeps the S3 location and the console reads the bytestype is logged/api/*Last verified: 2026-08-28 against main.