Devices API
Reference for the E2E-encryption device registry. See Devices for the conceptual overview and lifecycle.
Device object#
| Field | Type | Notes |
|---|---|---|
| id | string | dev_-prefixed stable identifier. |
| user_id | string | Owning user (usr_-prefixed). |
| tenant_id | string | Owning tenant (tnt_-prefixed). |
| kind | string | DESKTOP | MOBILE | SERVER | BROWSER | OTHER. Informational only — no ScaiKey authz decision is keyed off this value. |
| platform | string | null | Free-form identifier: macos, ios-17, android-14, linux, windows-11, etc. |
| user_agent | string | null | UA of the enrolling session. Only visible on /me/devices — omitted from directory queries. |
| display_name | string | null | User-supplied label, e.g. "Marcel's MacBook Pro". Omitted from directory queries. |
| kem_pub | string | Base64-encoded X25519 public key (32 bytes). Server emits url-safe base64 without padding. Server accepts both url-safe and standard base64 on input, with or without padding. |
| sig_pub | string | Base64-encoded Ed25519 public key (32 bytes). Same encoding rules as kem_pub. |
| key_version | integer | 1 at enrolment; bumps by one on each rotation. |
| keys_registered_at | string | ISO 8601 timestamp. |
| keys_rotated_at | string | null | ISO 8601, set on first rotation. Returned on every device query, including /tenants/{tid}/devices and /tenants/{tid}/groups/{gid}/devices — use for stale-cache detection. |
| last_seen | string | null | ISO 8601, updated on each heartbeat. null until first heartbeat. |
| online | boolean | Computed at query time from last_seen + a staleness threshold (default 90 s). false for devices that have never heartbeated. |
| revoked_at | string | null | ISO 8601 soft-delete timestamp. |
| created_at | string | ISO 8601 enrolment timestamp. |
POST /api/v1/me/devices#
Enrol a new device for the current session's user.
Auth: session-authenticated user token.
Headers:
Idempotency-Key(optional). If supplied AND the same key arrived before with identical(kem_pub, sig_pub), the existing device is returned with HTTP 200 and"idempotent": truein the response body — no duplicate row, no additional webhook fires. Different keys under the sameIdempotency-Keyare treated as legitimately different devices (fresh 201).X-Request-ID(optional). Echoed into thedevice.registeredwebhook envelope'srequest_idfield for correlation.
| Field | Type | Notes |
|---|---|---|
| kem_pub | string | Required. Base64-encoded X25519 public key (32 bytes). URL-safe or standard, padding optional. |
| sig_pub | string | Required. Base64-encoded Ed25519 public key (32 bytes). |
| kind | string | Optional. Default OTHER. |
| platform | string | Optional, max 100 chars. |
| display_name | string | Optional, max 255 chars. |
Response (201): {"data": <Device object>} — new row created.
Response (200): {"data": <Device object>, "idempotent": true} — idempotency replay; existing row returned.
Errors:
400 INVALID_PUBLIC_KEY— wire-format validation failed. Responsealgorithmandreasonidentify which key and why (wrong length, all-zeros, known small-order point).409 DEVICE_LIMIT_EXCEEDED— user already holds the maximum simultaneous devices (default 20; see Configuration). Revoke one before enrolling.
GET /api/v1/me/devices#
List the caller's devices, most-recently-seen first.
Auth: session-authenticated user token.
| Query param | Type | Notes |
|---|---|---|
| include_revoked | boolean | Optional. true to include soft-deleted entries. Default false. |
Response (200): {"data": [<Device>, ...]}.
PUT /api/v1/me/devices/{device_id}/keys#
Rotate a device's public keys.
Auth: session-authenticated user token.
| Field | Type | Notes |
|---|---|---|
| kem_pub | string | Required. New X25519 public key, base64. |
| sig_pub | string | Required. New Ed25519 public key, base64. |
Response (200): {"data": <Device object>} with key_version incremented.
Errors:
400 INVALID_PUBLIC_KEY— same as enrol.404 DEVICE_NOT_FOUND— the device doesn't exist, isn't yours, or is revoked (revoked devices refuse rotation).
Fires device.keys.rotated webhook.
POST /api/v1/me/devices/{device_id}/heartbeat#
Update the device's last_seen presence timestamp.
Auth: session-authenticated user token.
Body: empty.
Response (204): no body.
Does not fire a webhook. Heartbeats are noisy by construction; consumers derive online at read time.
DELETE /api/v1/me/devices/{device_id}#
Self-revoke a device (soft-delete).
Auth: session-authenticated user token.
Headers:
X-Request-ID(optional). Echoed into thedevice.revokedwebhook envelope.
| Query param | Type | Notes |
|---|---|---|
| reason | string | Optional. Canonical enum: user_revoked (default) | key_compromise_suspected | rotation_replacement. Free-form strings up to 255 chars accepted; non-canonical values may cause downstream consumers to fall back to generic handling. |
Response (204): no body.
Fires device.revoked webhook with actor_type=user. Idempotent: a second DELETE on the same device returns 404 (the first call moved the row out of the non-revoked view); no additional webhook fires.
GET /api/v1/tenants/{tenant_id}/devices#
Bulk directory lookup — return every live device for a set of user subjects.
Auth: session-authenticated user token; caller's tenant must match the path tenant_id unless caller holds a live super-admin role.
Response headers: Cache-Control: private, max-age=<devices.directory_cache_max_age_seconds> (default 30). Consumers should honour the hint and invalidate on the corresponding device.* webhook rather than polling.
| Query param | Type | Notes |
|---|---|---|
| subjects | string | Required. Comma-separated list of subjects. Canonical: usr_… stable ID (matches token subjects and webhook resource IDs). 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. Up to 100 entries per request. |
| include_revoked | boolean | Optional. Default false. |
Response (200):
1 2 3 4 | |
unknown_subjects combines two cases: subjects the server couldn't parse, and subjects that parsed correctly but resolved to a user with no live devices.
Errors:
400 TOO_MANY_SUBJECTS— more than 100 subjects.401 DIRECTORY_UNAUTHENTICATED— missing or invalid bearer token.403 CROSS_TENANT_DENIED— caller tenant doesn't match path tenant and caller is not super-admin.
GET /api/v1/tenants/{tenant_id}/groups/{group_id}/devices#
Transitive group → devices lookup — walk nested-group descendants and return every effective member's devices.
Auth: session-authenticated user token, same tenant scope as bulk lookup.
Response headers: Cache-Control: private, max-age=<devices.directory_cache_max_age_seconds> (default 30).
| Query param | Type | Notes |
|---|---|---|
| include_revoked | boolean | Optional. Default false. |
Response (200):
1 2 3 4 5 | |
data is deduplicated by device. effective_group_count reports how many groups the server walked (this group plus all descendants), useful for eyeballing whether the traversal reached the intended breadth.
Errors:
404 GROUP_NOT_FOUND— group doesn't exist in this tenant. The response does not leak existence for foreign-tenant groups.403 CROSS_TENANT_DENIED/401 DIRECTORY_UNAUTHENTICATED— as bulk.
POST /api/v1/devices/heartbeat#
Batched presence heartbeat — primary shape for a WS gateway or similar upstream service reporting liveness for many devices at once.
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 reach is rejected but doesn't fail the batch.
| Field | Type | Notes |
|---|---|---|
| heartbeats | array | Required. Up to 500 entries per request. Each entry: {"device_id": "dev_…", "at": "<ISO 8601>"}. |
Response (200):
1 2 3 4 5 6 7 8 | |
accepted counts entries whose last_seen was updated (or would have been, had the submitted at been newer than the stored value — a stale submission that gets ignored still counts as accepted since the device is known and live). Rejection reason values: unknown (device_id doesn't resolve), revoked (soft-deleted), cross_tenant (caller lacks scope).
Latest-write-wins on last_seen: two heartbeats for the same device with different at timestamps land at whichever is more recent, not whichever call happened to hit the DB last. A stale retry can never regress the value.
Errors:
400 BATCH_TOO_LARGE— more than 500 entries. Split into parallel batches.
POST /api/v1/devices/{device_id}/heartbeat#
Single-device presence heartbeat — for tests, debug tools, and low-fan-out edge cases where the batch endpoint would be overkill.
Auth: admin token or platform token.
| Query param | Type | Notes |
|---|---|---|
| at | string | Optional ISO 8601 timestamp. Defaults to the current server time. |
Response (204): no body.
Unlike the batch endpoint, this variant returns clear status codes for the failure cases:
404 DEVICE_NOT_FOUND— unknown device id.410 DEVICE_REVOKED— device is soft-deleted.403 CROSS_TENANT_DENIED— caller's admin scope doesn't reach the device's tenant.
POST /api/v1/admin/users/{user_id}/devices/{device_id}/revoke#
Admin revocation of a user's device.
Auth: admin token. Non-super-admins can only revoke devices in tenants they can reach.
Headers:
X-Request-ID(optional). Echoed into thedevice.revokedwebhook envelope.
| Field | Type | Notes |
|---|---|---|
| reason | string | Optional. Canonical enum: admin_revoked (default) | key_compromise_suspected | rotation_replacement | user_revoked. Free-form strings up to 255 chars accepted; non-canonical values may cause downstream consumers to fall back to generic handling. Persisted on the device row and echoed in the webhook payload. |
Response (200):
1 2 3 4 5 | |
On idempotent replay (device already revoked): returns 200 with already_revoked: true, no additional webhook fires.
Errors:
400 REASON_TOO_LONG— reason exceeds 255 chars.403— admin lacks the scope to touch that tenant.404 DEVICE_NOT_FOUND— no device with that(user_id, device_id)pair.
Fires device.revoked webhook with actor_type=admin on first revoke.
Webhook payloads#
All three events use the standard ScaiKey webhook envelope. If the triggering HTTP request carried an X-Request-ID header, the envelope includes a top-level request_id field with that value — consumers can correlate a webhook receipt back to the original API call that caused it.
Event-specific data payloads:
device.registered:
1 2 3 4 5 6 7 8 9 | |
device.keys.rotated:
1 2 3 4 5 6 7 8 | |
device.revoked:
1 2 3 4 5 6 | |
actor_type on the envelope is user for self-revoke, admin for admin revocation.
Canonical revoked_reason values (consumers may treat these as an enum):
| Value | When emitted |
|---|---|
user_revoked |
User self-revokes via DELETE /me/devices/{id} without an override reason. |
admin_revoked |
Admin revokes via the admin endpoint without an override reason. |
key_compromise_suspected |
Explicit override — signals a wider fan-out for downstream consumers (e.g. ScaiClip's rekey.required on all channels this device participated in). |
rotation_replacement |
The device replaced its own row after a backup-restore or fresh-install and is retiring the old entry. |
Free-form strings up to 255 chars are still accepted at the API layer; non-canonical values may cause downstream consumers to fall back to generic handling. Prefer the canonical enum.
Configuration#
| Setting | Env var | Default | Notes |
|---|---|---|---|
devices.online_threshold_seconds |
SCAIKEY_DEVICES__ONLINE_THRESHOLD_SECONDS |
90 | Max seconds since last_seen for a device to report online: true. Range 5–3600. |
devices.max_per_user |
SCAIKEY_DEVICES__MAX_PER_USER |
20 | Maximum simultaneously-live devices per user. Revoked devices don't count. Range 1–1000. |
devices.directory_cache_max_age_seconds |
SCAIKEY_DEVICES__DIRECTORY_CACHE_MAX_AGE_SECONDS |
30 | Cache-Control: private, max-age=<this> on directory responses. Range 0–3600. |
Related#
- Devices concept overview
- Webhooks
- Groups — the transitive membership walk backing group-devices lookup.