Devices API
Reference for the Devices endpoint group — 9 endpoints.
Generated from the live OpenAPI spec. Re-run _generate_api_reference.py after backend changes.
Authentication#
All endpoints require a Bearer JWT in the Authorization header unless noted otherwise. See Concepts → Tokens and scopes and Reference → OAuth endpoints for how to obtain one.
Endpoints#
POST /api/v1/devices/heartbeat#
Heartbeat Batch
Batched presence heartbeat.
Primary shape — a WS gateway posts one request per cadence carrying every device it observed. Response reports how many entries were applied and which were rejected (with reasons); a partial success is not an error.
Auth: admin token or platform token. Tenant scoping enforced per-device inside the batch: an entry for a device in a tenant the caller can't see is rejected but doesn't fail the batch.
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
authorization |
header | no | string | null |
Request body:
Required.
application/json→HeartbeatBatchRequest
Responses:
| Status | Body |
|---|---|
200 |
application/json → object |
422 |
application/json → HTTPValidationError |
POST /api/v1/devices/{device_id}/heartbeat#
Heartbeat Single
Single-device presence heartbeat.
Companion to the batch endpoint for tests and low-fan-out tools.
If at is omitted, uses the current server time. Rejects with
404 (unknown), 410 (revoked), or 403 (cross-tenant) rather than
the per-entry rejected structure — single-device semantics
prefer clear status codes.
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
device_id |
path | yes | string |
|
at |
query | no | string (date-time) | null |
|
authorization |
header | no | string | null |
Responses:
| Status | Body |
|---|---|
204 |
Successful Response |
422 |
application/json → HTTPValidationError |
GET /api/v1/me/devices#
List My Devices
List the caller's devices, most-recent first.
include_revoked=true includes soft-deleted entries — useful
for a "devices you've ever registered" view. Defaults to live only.
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
include_revoked |
query | no | boolean |
|
authorization |
header | no | string | null |
Responses:
| Status | Body |
|---|---|
200 |
application/json → object |
422 |
application/json → HTTPValidationError |
POST /api/v1/me/devices#
Enrol Device
Enrol a new device with its two public keys.
Response codes:
- 201 — a new device row was created.
- 200 — Idempotency-Key match: the same key arrived before with
identical
(kem_pub, sig_pub); the existing device is returned unchanged, no webhook fires. - 409
IDEMPOTENCY_CONFLICT— the same key arrived before but with a different body. Client bug or replay from a different device state. - 409
DEVICE_LIMIT_EXCEEDED— user already holdssettings.devices.max_per_userlive devices. Revoke one first.
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
Idempotency-Key |
header | no | string | null |
Optional client-supplied key. If a request with the same Idempotency-Key and identical (kem_pub, sig_pub) arrives within 24h, ScaiKey returns the existing device row with HTTP 200 rather than creating a duplicate. Conflicting bodies under the same key return 409 IDEMPOTENCY_CONFLICT. |
X-Request-ID |
header | no | string | null |
|
authorization |
header | no | string | null |
Request body:
Required.
application/json→DeviceEnrolRequest
Responses:
| Status | Body |
|---|---|
200 |
application/json → object |
422 |
application/json → HTTPValidationError |
DELETE /api/v1/me/devices/{device_id}#
Revoke My Device
User self-revokes a device.
Soft-delete — sets revoked_at and revoked_reason,
preserves the row. Fires device.revoked. Subsequent directory
lookups filter this device out; audit and forensic queries can
still see it. Idempotent: a second DELETE on the same device
returns 404 because the first one moved the row out of the
non-revoked view; no additional webhook fires.
Args:
reason: Optional query-param override picking a canonical
reason from _REVOKED_REASONS. Default user_revoked.
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
device_id |
path | yes | string |
|
reason |
query | no | string | null |
|
X-Request-ID |
header | no | string | null |
|
authorization |
header | no | string | null |
Responses:
| Status | Body |
|---|---|
204 |
Successful Response |
422 |
application/json → HTTPValidationError |
POST /api/v1/me/devices/{device_id}/heartbeat#
Heartbeat Device
Update the device's last_seen timestamp.
Deliberately shaped as a fire-and-forget 204. The device (or an
upstream service acting on its behalf — ScaiGrid's WS layer for
ScaiClip) posts on a cadence; consumers derive online at
query time from last_seen + a staleness threshold. No webhook
fires — heartbeats are noisy by construction and consumers don't
want to invalidate caches on every one.
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
device_id |
path | yes | string |
|
authorization |
header | no | string | null |
Responses:
| Status | Body |
|---|---|
204 |
Successful Response |
422 |
application/json → HTTPValidationError |
PUT /api/v1/me/devices/{device_id}/keys#
Rotate Device Keys
Rotate a device's public keys.
Bumps key_version by one, sets keys_rotated_at, replaces
both public keys in one transaction, fires
device.keys.rotated. The old public key material is
overwritten — we don't keep a history table (that's the client's
concern for its channel-rewrap flow). Revoked devices refuse
rotation.
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
device_id |
path | yes | string |
|
X-Request-ID |
header | no | string | null |
|
authorization |
header | no | string | null |
Request body:
Required.
application/json→DeviceRotateKeysRequest
Responses:
| Status | Body |
|---|---|
200 |
application/json → object |
422 |
application/json → HTTPValidationError |
GET /api/v1/tenants/{tenant_id}/devices#
Bulk Lookup Devices
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenant_id |
path | yes | string |
|
subjects |
query | yes | string |
Comma-separated list of user subjects. Canonical form: usr_… stable ID. URN form urn:scaikey:{tenant_slug}:usr:{user_id} also accepted as a compatibility shape for SCWP-native callers. Both resolve to the same user. Capped at 100 entries per request. |
include_revoked |
query | no | boolean |
Include soft-deleted devices in the response. |
authorization |
header | no | string | null |
Responses:
| Status | Body |
|---|---|
200 |
application/json → object |
422 |
application/json → HTTPValidationError |
GET /api/v1/tenants/{tenant_id}/groups/{group_id}/devices#
Group Devices Transitive
Parameters:
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenant_id |
path | yes | string |
|
group_id |
path | yes | string |
|
include_revoked |
query | no | boolean |
Include soft-deleted devices. |
authorization |
header | no | string | null |
Responses:
| Status | Body |
|---|---|
200 |
application/json → object |
422 |
application/json → HTTPValidationError |
Schemas#
Definitions for every type referenced by the endpoints above. Schema-to-schema references on this page link within the page; cross-page references would require visiting the linked page.
DeviceEnrolRequest#
Request body for enrolling a new device.
| Field | Type | Required | Description |
|---|---|---|---|
kem_pub |
string |
yes | X25519 KEM public key, 32 bytes, base64-encoded (url-safe or standard). |
sig_pub |
string |
yes | Ed25519 signing public key, 32 bytes, base64-encoded. |
kind |
UserDeviceKind |
no | Informational device category — 'DESKTOP', 'MOBILE', etc. |
platform |
string | null |
no | Platform identifier — 'macos', 'ios-17', 'android-14', etc. |
display_name |
string | null |
no | User-supplied label — 'Marcel's MacBook Pro'. Optional. |
DeviceRotateKeysRequest#
Request body for rotating a device's keys.
| Field | Type | Required | Description |
|---|---|---|---|
kem_pub |
string |
yes | New X25519 KEM public key, base64-encoded. |
sig_pub |
string |
yes | New Ed25519 signing public key, base64-encoded. |
HTTPValidationError#
| Field | Type | Required | Description |
|---|---|---|---|
detail |
array of ValidationError |
no |
HeartbeatBatchRequest#
| Field | Type | Required | Description |
|---|---|---|---|
heartbeats |
array of HeartbeatEntry |
no | One entry per observed device. Cap: 500 per request. |
HeartbeatEntry#
| Field | Type | Required | Description |
|---|---|---|---|
device_id |
string |
yes | |
at |
string (date-time) |
yes | ISO 8601 timestamp of the observed activity. Ignored on the server if older than the row's current last_seen (latest-write-wins). |
UserDeviceKind#
Broad category of the device — informational only.
Callers surface this in UI ("your Mac", "your phone") and can filter
on it in the directory query (e.g. "give me only mobile devices"),
but no ScaiKey authz decision is keyed off kind. Add new
variants freely; do not remove existing ones without a migration.
Type: string (enum)
Values: DESKTOP, MOBILE, SERVER, BROWSER, OTHER
ValidationError#
| Field | Type | Required | Description |
|---|---|---|---|
loc |
array of string | integer |
yes | |
msg |
string |
yes | |
type |
string |
yes |