Plattform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Modelle Tools & Services
Lösungen
Organisationen Entwickler Internet Service Provider Managed Service Provider AI-in-a-Box
Ressourcen
Support Documentation Blog Downloads
Unternehmen
Über uns Forschung Karriere Investieren Kontakt
Anmelden

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": true in the response body — no duplicate row, no additional webhook fires. Different keys under the same Idempotency-Key are treated as legitimately different devices (fresh 201).
  • X-Request-ID (optional). Echoed into the device.registered webhook envelope's request_id field 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. Response algorithm and reason identify 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 the device.revoked webhook 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):

json
1
2
3
4
{
  "data": [ <Device object>, ... ],
  "unknown_subjects": [ "usr_bogus", "malformed-input" ]
}

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

json
1
2
3
4
5
{
  "data": [ <Device object>, ... ],
  "group_id": "grp_…",
  "effective_group_count": 3
}

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

json
1
2
3
4
5
6
7
8
{
  "accepted": 42,
  "rejected": [
    { "device_id": "dev_bogus", "reason": "unknown" },
    { "device_id": "dev_x",     "reason": "revoked" },
    { "device_id": "dev_y",     "reason": "cross_tenant" }
  ]
}

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 the device.revoked webhook 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):

json
1
2
3
4
5
{ "data": {
    "device_id": "dev_…",
    "revoked_at": "2026-07-11T14:00:00Z",
    "revoked_reason": "compromised"
} }

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:

json
1
2
3
4
5
6
7
8
9
{
  "user_id": "usr_…",
  "device_id": "dev_…",
  "kem_pub": "…base64…",
  "sig_pub": "…base64…",
  "key_version": 1,
  "kind": "DESKTOP",
  "platform": "macos"
}

device.keys.rotated:

json
1
2
3
4
5
6
7
8
{
  "user_id": "usr_…",
  "device_id": "dev_…",
  "kem_pub": "…base64…",
  "sig_pub": "…base64…",
  "key_version": 2,
  "keys_rotated_at": "2026-07-11T14:00:00Z"
}

device.revoked:

json
1
2
3
4
5
6
{
  "user_id": "usr_…",
  "device_id": "dev_…",
  "revoked_reason": "user_revoked",
  "revoked_at": "2026-07-11T14:00:00Z"
}

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.

Updated 2026-07-11 03:09:26 View source (.md) rev 4