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.
macOS security reports are separate from activity capture and are collected outside
work hours. POST /endpoint/security_reports uses the enrolled device's
X-Device-Token header. The same report can be sent through the existing cable
report action with data.type = "security_report". Disabled devices and revoked
owners cannot submit security evidence. Security observations are agent-reported,
not hardware attestation or proof that a policy was enforced.
Schema version 1 has a UUID report_id, UTC observed_at, platform: "macos",
agent/OS versions, per-check posture state/source/detail, installed applications,
periodic processes, truncation indicators and explicit capabilities. Optional
malware_scan describes the managed sample inbox or locally approved folders, with opaque scope labels, coverage limits, engine/signature versions,
a stable scan observation timestamp (preserved between scheduled scans), definition freshness, file/error counts and SHA-256 detection evidence. Optional
endpoint_security describes the separately entitled sensor's runtime state,
receipt-timestamped process/persistence notifications and loss counters. Its
informational persistence rules identify LaunchAgent/LaunchDaemon creation or
replacement; they do not classify all such activity as malware or block it.
Reports are at most 512 KiB, 1000 installed applications, 2000 processes, 100 scan
detections and 128 sensor events. Collection excludes process arguments, environment
variables and user paths. The server discards fields outside this contract and
filters report bodies from application parameter logs. A shared, device-locked
quota permits 24 new reports per hour across HTTP and cable. Repeating the same UUID
and evidence is idempotent; reusing a UUID with changed evidence fails.
The console and the owning partner's /partner/api/v1/endpoints response show the
latest observed report. Organization CSV export is
GET /endpoint_console/devices/security_export. Unknown, unavailable, stale and
policy-changed evidence never receives a passing posture verdict. CSV values are
protected against spreadsheet formula evaluation.
Organization administrators can append a versioned security evidence policy with
a minimum numeric macOS version, required MDM observation and a 15–1440 minute
freshness window. Policy changes retain the requester's identity and time. Reports
pin the evaluated policy version, and a new policy requires fresh device evidence.
The default freshness window is 120 minutes. Policy evaluation does not install OS
updates, enable FileVault or enroll a device into MDM.
Administrators can request refresh_security_report, scan_managed_samples or scan_approved_folders. The last action is available only when a recent device report advertises locally approved folders; the server cannot select or change those folders.
The agent polls GET /endpoint/security_commands, receives a UUID/action/expiry,
then uses PATCH /endpoint/security_commands/:id to acknowledge completed or
failed, with a short enum detail and a stored report_id. Commands expire in
15 minutes. Completion requires fresh evidence from that device; successful scan
completion also requires the requested scope, at least one scanned file, no errors, no skipped files, complete reported scope and fresh definitions.
Repeated identical results are accepted, but terminal results cannot be changed.
No remote path, shell command, isolation, erase or arbitrary quarantine action is
accepted.
With separate, enrollment-bound local consent, administrators can also request
quarantine_managed_sample with {source_report_id, sha256} or
restore_managed_sample with {quarantine_id, sha256}. References are opaque UUIDs
and SHA-256 values; no remote path is accepted. Quarantine requires a recent,
complete, fresh-definition managed-inbox detection. The agent rescans and verifies
the single eligible regular file before moving it. Restore requires an existing
confirmed quarantine for the same device and refuses destination collisions.
These operations never target locally approved general scan folders. The shared
authorizer permits four response requests per rolling hour and 24 per day under
the device lock, including requests that subsequently fail or expire.
A report may contain at most 20 response_receipts, each binding command, action,
source report (quarantine), SHA-256, quarantine UUID, terminal status, short detail,
observation time and reconciled boolean. Both successful and failed acknowledgments
require a matching persisted receipt. Result audit rows are immutable. Successful
PATCH responses include the command id and terminal status; legacy command
envelopes omit response arguments entirely.
GET returns at most ten commands, prioritizing unexpired requests, then pending
managed-response requests expired within 30 days for journal reconciliation. An
expired request cannot initiate a new file mutation. A late completion requires
an original receipt observed before expiry, or an explicit reconciled: true
receipt recording when the agent verified an already executed action. This is
retrospective evidence, not a fabricated execution timestamp. Local journal I/O
uncertainty leaves the request pending and reports managed_response posture
error with response_reconciliation_required.
Security report bodies, including inventory and sensor snapshots, are stored in
Endpoint's database for 30 days and purged hourly. This is durable storage, not a
pass-through promise. Policy versions and response request/result audit rows remain
until the owning device/organization is deleted; they contain identifiers and status,
not raw file contents or recovery keys. Existing activity/screenshot retention is
separate and is still tracked by issue #167.