How a partner (first consumer: Uncover.com) enrolls end-user devices into Endpoint
monitoring and learns when a device has registered.
This is not the proctoring partner surface. PartnerWebhookService,
SendPartnerWebhookJob, Admin::PartnersController and the /webhooks/* routes all
belong to the proctoring product and its ProctoringClient tenants. The Partner API
has its own tenant model (Partner, table partners), its own admin console
(Admin::ApiPartnersController at /admin/api_partners), and its own webhook
contract. Nothing below authenticates against ClientAuthenticator.
Partner backend
→ POST /partner/api/v1/enrollments (Bearer API key; mints a QR payload)
Partner renders the QR; end user scans it with the Endpoint macOS app
→ POST /endpoint/register (the app, unauthenticated, token in body)
Endpoint::RegistrationsController allocates the device to partner + euid
→ Partners::WebhookDeliveryJob (endpoint.registered → partner.webhook_url)
Partner backend (any time)
→ GET /partner/api/v1/endpoints?euid=... (pull: which devices are enrolled?)
→ GET /partner/api/v1/endpoints/:id/events (pull: telemetry)
→ GET /partner/api/v1/endpoints/:id/violations (pull: telemetry)
→ GET /partner/api/v1/endpoints/:id/events/:event_id/screenshot
Partners are created and approved by an operator in the admin console
(/admin/api_partners). Approval is a human decision on purpose: no API key can
exist before approval (PartnerApiKey.issue! raises), and approving mints the
webhook signing secret plus the first key in one step.
The raw API key is displayed exactly once, on the page rendered immediately after
approval or key issuance. Only its SHA-256 digest is stored (PartnerApiKey,
imitating Tahometer::AgentToken), so a lost key means issuing a new one. Two keys
may be live at once for rotation; revocation (per key, in the console) takes effect
on the very next request, as does revoking the partner's approval.
POST /partner/api/v1/enrollments
Authorization: Bearer <api key>
Content-Type: application/json
{"euid": "alice-123"}
euid is the partner's own identifier for the end user: a non-empty string of at
most 255 characters (anything else — including a JSON array or object — is a 422).
Response, HTTP 201:
{
"euid": "alice-123",
"qr_payload": "ENDPOINT_AUTH|<token>|https://pro.endpoint.app",
"uri": "endpoint://enroll/<token>",
"expires_at": "2026-08-27T13:00:00Z"
}
Errors follow the {"error": {"code": ..., "message": ...}} shape:
missing_token/invalid_token (401), invalid_euid (422 on a blank euid),
rate_limited (429 — the whole /partner/api/v1/ surface is throttled per API key,
60 requests/minute, config/initializers/rack_attack.rb).
The token (Endpoint::EnrollmentTokenService.generate_for_partner) is HMAC-signed
with the partner's own enrollment secret, binds this partner and this euid, and
expires one hour after minting. Mint at the moment of display, not in batches.
One euid may enroll any number of devices — mint a fresh enrollment for each.
The third qr_payload field is the server URL the device will call. It defaults to
the request's base URL and can be pinned with the ENDPOINT_SERVER_URL environment
variable (PartnerApi::V1::EnrollmentsController#server_url).
The macOS scanner (clients/macos/Endpoint/QRScannerView.swift) requires exactly
three pipe-separated parts, the first being ENDPOINT_AUTH — encode qr_payload
verbatim, never append fields. Two rendering properties are load-bearing (see
docs/guides/qr-integration.md and EndpointConsole::EnrollmentController for the
reference implementation):
viewbox: true (SVG) — without a viewBox, CSS sizing clips the code insteadoffset:, not CSS:m, module_size: 6,offset: 24.The unmodified shipped binary POSTs, with no auth header:
POST /endpoint/register
Content-Type: application/json
{
"user_id": "<token>",
"device_info": {"hostname": "...", "os_version": "...", "app_version": "...", "serial_number": "..."}
}
Client 1.7+ also sends serial_number — the hardware serial read from IOKit
(IOPlatformSerialNumber) — and omits the key entirely when the lookup fails.
Clients <= 1.6 never send it. The server copies a supplied serial into the
device's serial_number column and leaves the column untouched when the key
is absent, so an older client re-registering cannot erase a captured serial.
The token travels in the user_id field — that is the shipped client's whole body
(QRScannerView.swift:207); an enrollment_token field is also accepted for
future clients. Endpoint::RegistrationsController tries the partner dialect first
(EnrollmentTokenService.validate_partner; the two token formats are
prefix-discriminated, so neither path can ever consume the other's tokens), creates
or updates the device under the partner with its euid, and answers the legacy
contract the binary expects — HTTP 200 with device_token — or 401
{"status":"error","message":"Invalid or expired enrollment token"}.
Devices dedup on (euid, hostname) only when the hostname identifies a machine:
the same laptop re-scanning updates its row; a different machine for the same euid
creates a second row. A blank or "Unknown" hostname (the Swift client sends
Host.current().localizedName ?? "Unknown") always creates a new device — two
anonymous machines must never share a row, because the second would receive the
first's device_token. A concurrent race on the same (euid, hostname) can still
produce two rows (the index is deliberately non-unique); the cost is a duplicate
row and webhook, never a shared credential. Partner-owned rows leave user_id
empty (on legacy proctoring-client rows that column stores the token itself; do
not query it).
Sent once per newly enrolled device, only if the partner has a webhook_url
(configured at creation in the admin console and validated as https:// — the body
carries euids and device identifiers; the pull API works without one).
POST <partner.webhook_url>
Content-Type: application/json
X-Endpoint-Signature: sha256=<HMAC-SHA256 hex of the raw request body>
X-Endpoint-Timestamp: <unix seconds>
{
"event": "endpoint.registered",
"data": {
"euid": "alice-123",
"device_id": 42,
"hostname": "Alices-MacBook-Pro",
"registered_at": "2026-08-27T12:05:11Z"
},
"timestamp": 1787832311
}
The HMAC key is the partner's webhook secret, minted at approval and visible to
operators on the partner's admin page (unlike API keys it must be stored server-side
to sign with, so it is not hashed). The signature covers the exact body bytes; the body's timestamp
equals the header, so verifying the signature and bounding the timestamp gives
replay protection. Verify like this:
def verify_endpoint_webhook(raw_body, headers, webhook_secret, tolerance: 300)
signature = headers["X-Endpoint-Signature"].to_s.delete_prefix("sha256=")
expected = OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, raw_body)
return false unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
(Time.now.to_i - headers["X-Endpoint-Timestamp"].to_i).abs <= tolerance
end
Verify against the raw request body, before any JSON parsing or re-serialisation
— re-encoding reorders nothing today, but that is luck, not contract.
Delivery is Partners::WebhookDeliveryJob: a 2xx completes it; a 5xx or any
network-layer failure (timeout, refused/reset connection, DNS, TLS) retries with
polynomial backoff, 5 attempts, after which the dropped event is logged; a 4xx is
treated as a permanent rejection and dropped. Answer 2xx quickly and do your
processing async — a missed event is recoverable any time from the pull API below.
These headers are not the proctoring product's X-Webhook-Signature scheme.
The two coexist deliberately; do not point both products at one verifier without
branching on the header name.
GET /partner/api/v1/endpoints
GET /partner/api/v1/endpoints?euid=alice-123
Authorization: Bearer <api key>
{
"endpoints": [
{
"id": 42,
"euid": "alice-123",
"hostname": "Alices-MacBook-Pro",
"serial_number": "C02XK1ZLJGH5",
"os_version": "Version 15.1 (Build 24B83)",
"app_version": "1.7.0",
"status": "registered",
"last_ip": "81.2.69.160",
"location": {
"country": "United Kingdom",
"country_code": "GB",
"region": null,
"city": null,
"latitude": null,
"longitude": null,
"source": "ip"
},
"last_seen_at": "2026-08-27T12:06:02Z",
"registered_at": "2026-08-27T12:05:11Z"
}
]
}
Strictly scoped to the authenticated partner (PartnerApi::V1::EndpointsController
starts every query from current_partner.endpoint_devices); another partner's
devices are structurally unreachable. status is one of registered, online,
offline, disabled; last_seen_at is null until the device first checks in.
serial_number is the hardware serial reported by the client at registration;
it is null for devices registered by client <= 1.6 until they re-register with
1.7+ (and stays null on machines where the client cannot read a serial).
os_version and app_version are likewise client-reported and may be null.
last_ip is the source IP of the device's most recent registration or login,
null for devices that predate IP capture. location is derived from last_ip
by a local GeoIP lookup (GeoipService), never from the device itself, so it
is an approximation — the IP's registered location, not GPS. With the default
database it is country-level only: region, city, latitude and
longitude are explicit nulls. Deployments that configure a City-level MMDB
via GEOIP_DB_PATH fill those fields where the database carries them (see
db/geoip/README.md). location is null whenever it cannot be derived:
missing last_ip, private/loopback IPs (a device behind the same LAN as the
server, or local development), IPs absent from the database, or no database
file. source is always "ip" — a marker so future location sources can be
distinguished.
IP geolocation for the default database is provided by
DB-IP (IP Geolocation by DB-IP, CC BY 4.0).
Once a device is enrolled and reporting, three sibling routes pull what it
has reported — same bearer auth, same throttle, same structural scoping
(another partner's device or event is a 404 whatever parameters arrive):
GET /partner/api/v1/endpoints/:id/events
GET /partner/api/v1/endpoints/:id/violations
GET /partner/api/v1/endpoints/:id/events/:event_id/screenshot
Parameters, payloads, pagination and the screenshot availability/offload
semantics (including the 410 for screenshots already in your own S3 bucket)
are documented in the
device telemetry doc, which also covers
what the device reports in the first place.
Screenshot bytes live in the primary database until offloaded to your S3
bucket (#196), so the bytes route serves them from every web pod and across
restarts — available: true in the events list means the pull will succeed.
An event that says available: false has no bytes anywhere and the bytes
route answers a clean 404. Retention is currently indefinite; this is not a
contractual guarantee — a retention policy is tracked in #167, and until it
lands each monitored device grows the database by roughly 500 KB per 10
minutes (one ~500 KB frame every 600 s). Configuring S3 offload moves each
frame into your own bucket almost immediately instead.
split("|", 3) and the Swift scanner guardscomponents.count == 3. The partner dialect exists precisely because of this;| cannotexpires_at is one hour out. A QR left on screen past that 401s on scan with