# Partner API (device enrollment control plane)

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

## The shape of it

```
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
```

## 0. Getting credentials

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.

## 1. Minting an enrollment

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

### Rendering the QR

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 instead
  of scaling it.
- **A quiet zone of about 4 modules** — with rqrcode that is `offset:`, not CSS
  padding. Reference numbers: error correction level `:m`, `module_size: 6`,
  `offset: 24`.

## 2. What the device does with it

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

## 3. The endpoint.registered webhook

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:

```ruby
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.

## 4. Pulling enrollment state

    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](https://db-ip.com) (IP Geolocation by DB-IP, CC BY 4.0).

## 5. Pulling telemetry

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](/docs/partners/device-telemetry), 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.

## Traps

- **The enrollment token format cannot be extended by appending fields.** The legacy
  validator does `split("|", 3)` and the Swift scanner guards
  `components.count == 3`. The partner dialect exists precisely because of this;
  its euid is base64url-encoded inside the payload so an euid containing `|` cannot
  smuggle fields into the signed string.
- **Tokens die with approval.** Revoking a partner's approval invalidates its API
  keys and all outstanding enrollment tokens at once; because tokens are stateless,
  re-approving brings unexpired ones back to life. Revoke individual keys for
  routine rotation; revoke approval to cut a partner off entirely.
- **`expires_at` is one hour out.** A QR left on screen past that 401s on scan with
  the same message as a garbage token; mint a fresh enrollment rather than diagnosing
  the device.
