← Partner Documentation

Device telemetry (what the device app reports, and where it goes)

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.

The shape of it

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

1. Connection and authentication

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 by
    POST /endpoint/register or POST /endpoint/login. An unknown token is
    rejected.
  • X-User-Id (legacy) — the scanned QR enrollment token, which the shipped
    macOS 1.6.0 client presents instead of the device token. It is accepted only
    when it matches an enrolled, non-disabled device; every acceptance writes a
    [DEPRECATION] log line, and the path will be removed once client 1.7 sends
    X-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.

2. The channel protocol

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]
  }
}

Report framing

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).

Device → server: update_work_hours

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.

Server → device frames

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

3. Telemetry catalogue

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.

screenshot

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.

process_list

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
}

app_change

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"
}

app_violation

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"
}

browser_url

Sent when the active browser's URL changes.

{
  "type": "browser_url",
  "url": "https://example.com/page",
  "browser": "Safari",
  "timestamp": "2026-08-28T12:00:00Z"
}

motion_detected

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"
}

no_motion

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"
}

monitoring_status

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"
}

4. Storage and retention

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.

5. Getting it back out

The Endpoint console at /endpoint_console (session login) is the primary
surface:

  • dashboard — device counts and recent events
  • device list, and a per-device page with recent events, violations and a
    screenshot gallery (GET /endpoint_console/devices/:id/screenshot/:event_id
    serves the image bytes, from the database or from the org's S3 bucket after
    offload)
  • disable / enable a device
  • org settings — blacklist, whitelist, work hours
  • QR enrollment

The 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.

Events

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).

Violations

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
}

Screenshot bytes

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).

Traps

  • The report frame is nested. The channel reads data.data from the
    decoded frame; a flat {"action":"report","type":"screenshot",...} frame is
    silently ignored — no error, no row.
  • motion_detected stores no row. It only refreshes the device's
    last-activity timestamp; don't search endpoint_events for it. Absence of
    motion is what gets a row (no_motion).
  • Offloaded screenshots disappear from the database by design. After the
    S3 upload succeeds the endpoint_screenshots blob is destroyed; the
    endpoint_events row keeps the S3 location and the console reads the bytes
    back from the bucket.
  • Unknown event types are dropped without error. A typo'd type is logged
    server-side and discarded; the device gets no failure signal.
  • The Linux client delivers none of this today. It posts to an /api/*
    surface this server does not implement, so the telemetry above comes from
    the macOS app only.

Last verified: 2026-08-28 against main.

macOS security evidence and response

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.