---
title: Devices API
path: reference/api/devices
status: published
---

# Devices API

Reference for the E2E-encryption device registry. See [Devices](/docs/scaikey/concepts/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](#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
{
  "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
{
  "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
{
  "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
{ "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
{
  "user_id": "usr_…",
  "device_id": "dev_…",
  "kem_pub": "…base64…",
  "sig_pub": "…base64…",
  "key_version": 1,
  "kind": "DESKTOP",
  "platform": "macos"
}
```

**`device.keys.rotated`**:
```json
{
  "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
{
  "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. |

---

## Related

- [Devices concept overview](/docs/scaikey/concepts/devices)
- [Webhooks](/docs/scaikey/reference/api/webhooks)
- [Groups](/docs/scaikey/reference/api/groups) — the transitive membership walk backing group-devices lookup.
