Application Settings & Devices

The Application Settings API is a generic per-user, per-app store for a client application's own settings, plus a device registry so those settings can follow the account across machines. It is the server side of the "Applications" feature: a native or web client (the first consumer is the Visual Introspection macOS app) reads and writes an opaque settings document, registers the machines it runs on, and resolves what a brand-new machine should start from on first run.

Every endpoint is authorized by Authorization: Bearer <sync-token> (or the web session cookie) and scoped to the authenticated user. No subscription is required. A token can only read and write its own user's documents; cross-user access is not possible.

Scopes: account vs device

Settings live in one of two scopes, both using the same document shape and the same optimistic-concurrency (compare-and-swap) rules:

  • Account scope (shared): one document per app that follows the user across every machine. Path: /api/user/app-settings/{appKey}.
  • Device scope (per-machine): one document per registered device, pinned to that machine. Path: /api/user/app-settings/{appKey}/devices/{deviceId}/settings. A device must be registered before its device-scoped document can be written.

The document is opaque to the server: it is stored and returned verbatim, byte for byte. Keys are never dropped, renamed, or reordered, even if a key is named like a secret (token, apiKey, and so on). The server does not interpret the payload and never logs it.

The settings document

Every read (and every successful write) returns a settings document with this shape:

FieldTypeDescription
appKeystringThe application namespace this document belongs to.
scopestring"account" for the shared document, "device" for a per-machine document.
deviceIdstring | nullThe device this document is pinned to; null for account scope.
versionintegerMonotonic version, starts at 1 on the first successful write and increments by 1 per write. Used for compare-and-swap.
updatedAtstring (ISO 8601)When the document was last written.
schemaVersionintegerClient-owned schema version, echoed back as sent. Defaults to 1 when omitted on write.
settingsobjectThe opaque, client-owned JSON object. Round-trips byte for byte.

Validation and limits

RuleValueOn violation
appKey format^[a-z0-9][a-z0-9-]{0,63}$ (lowercase alphanumeric + hyphen, 1 to 64 chars, must start alphanumeric)400
deviceId format^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$ (alphanumeric plus . _ : -, 8 to 128 chars, must start alphanumeric)400
settings size cap64 KB (65536 bytes), measured as the UTF-8 byte length of the serialized settings object only, not the whole request envelope413
settings typeMust be a JSON object (not an array, string, or null)400
Write rate limit~60 writes per minute per user per app (shared across all write methods on the same appKey)429

Reads are not rate-limited. The rate-limit bucket is keyed per user and per appKey, so writes to the account document, device documents, and the device registry for one app all draw from the same ~60/min budget.

Optimistic concurrency (compare-and-swap)

Writes to a settings document use body-baseVersion compare-and-swap so concurrent writers cannot silently overwrite each other:

  • First-ever write: send baseVersion: 0. The server creates the document at version: 1.
  • Update: send baseVersion equal to the document's current version. The server writes and increments version.
  • Stale write: if baseVersion does not match (including sending a non-zero baseVersion when no document exists yet), the server returns 409 with the full current document under current, so the client can rebase and retry.

Error responses

Errors return the standard InterlinedList shape: a human-readable error string plus a stable machine-readable code.

{ "error": "Not found", "code": "not_found" }
StatuscodeCondition
400bad_requestInvalid appKey, invalid deviceId, unparseable JSON body, or a body that fails validation (bad baseVersion, settings not an object, and so on).
401unauthorizedNo valid sync token or session.
404not_foundNo document exists at that scope, the device is not registered (device settings PUT), the device does not exist (device PATCH/DELETE), or bootstrap resolved to none.
409version_conflictbaseVersion did not match; the body includes current (the full current document, or null when a create raced).
413payload_too_largeSerialized settings exceeds 64 KB.
429rate_limitedOver ~60 writes/min. Includes RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After headers.
500internal_errorUnexpected server error.

Endpoint summary

MethodPathAuthDescription
GET/api/user/app-settings/{appKey}Session or BearerRead the account (shared) settings document.
PUT/api/user/app-settings/{appKey}Session or BearerCreate or update the account document (compare-and-swap).
DELETE/api/user/app-settings/{appKey}Session or BearerDelete the account document.
GET/api/user/app-settings/{appKey}/devices/{deviceId}/settingsSession or BearerRead a device's settings document.
PUT/api/user/app-settings/{appKey}/devices/{deviceId}/settingsSession or BearerCreate or update a device's settings document (device must be registered).
GET/api/user/app-settings/{appKey}/devicesSession or BearerList registered devices for this user and app.
POST/api/user/app-settings/{appKey}/devicesSession or BearerRegister or refresh a device. First device becomes the default.
PATCH/api/user/app-settings/{appKey}/devices/{deviceId}Session or BearerRename a device and/or promote it to default.
DELETE/api/user/app-settings/{appKey}/devices/{deviceId}Session or BearerDeregister a device and delete its device settings.
GET/api/user/app-settings/{appKey}/bootstrap?deviceId=...Session or BearerResolve what a fresh machine should seed from.

Account-scoped settings

GET /api/user/app-settings/{appKey}

Auth required: yes Description: Read the account (shared) settings document.

Path parameters

ParamTypeDescription
appKeystringApplication namespace (validated against the appKey format).

Response 200 OK

{
  "appKey": "visual-introspection",
  "scope": "account",
  "deviceId": null,
  "version": 7,
  "updatedAt": "2026-08-15T18:04:22.000Z",
  "schemaVersion": 1,
  "settings": { "theme": "dark", "recentProjects": ["/a", "/b"] }
}

Error responses

StatusCondition
400Invalid appKey.
401Not authenticated.
404Nothing synced yet for this app.

PUT /api/user/app-settings/{appKey}

Auth required: yes Description: Create or update the account (shared) settings document using body-baseVersion compare-and-swap.

Path parameters

ParamTypeDescription
appKeystringApplication namespace.

Request body

{
  "baseVersion": 7,
  "schemaVersion": 1,
  "settings": { "theme": "dark", "recentProjects": ["/a", "/b"] }
}
FieldTypeRequiredDescription
baseVersionintegeryesThe version the write is based on. 0 on the first-ever write.
schemaVersionintegernoClient schema version, echoed back. Defaults to 1. Must be an integer >= 0.
settingsobjectyesOpaque JSON object. Stored and returned verbatim. Must be <= 64 KB serialized.

Response 200 OK: the newly stored document (with version incremented):

{
  "appKey": "visual-introspection",
  "scope": "account",
  "deviceId": null,
  "version": 8,
  "updatedAt": "2026-08-15T18:10:00.000Z",
  "schemaVersion": 1,
  "settings": { "theme": "dark", "recentProjects": ["/a", "/b"] }
}

Response 409 Conflict (baseVersion did not match):

{
  "error": "version_conflict",
  "code": "version_conflict",
  "current": {
    "appKey": "visual-introspection",
    "scope": "account",
    "deviceId": null,
    "version": 9,
    "updatedAt": "2026-08-15T18:12:00.000Z",
    "schemaVersion": 1,
    "settings": { "theme": "light" }
  }
}

current is the full current document. It is null in the rare case where a concurrent create won the race and the current row could not be re-read.

Error responses

StatusCondition
400Invalid appKey, unparseable JSON, or invalid body (baseVersion not an integer >= 0, settings not an object).
401Not authenticated.
409baseVersion did not match; body includes current.
413Serialized settings exceeds 64 KB.
429Over the write rate limit.

DELETE /api/user/app-settings/{appKey}

Auth required: yes Description: Delete the account (shared) settings document. Idempotent.

Response 200 OK

{ "deleted": true }

deleted is true when a document was removed, false when there was nothing to delete.

Error responses

StatusCondition
400Invalid appKey.
401Not authenticated.
429Over the write rate limit.

Device-scoped settings

GET /api/user/app-settings/{appKey}/devices/{deviceId}/settings

Auth required: yes Description: Read a device's settings document.

Path parameters

ParamTypeDescription
appKeystringApplication namespace.
deviceIdstringClient-stable device id (validated against the deviceId format).

Response 200 OK

{
  "appKey": "visual-introspection",
  "scope": "device",
  "deviceId": "macbook-pro-9F2C1A7B4D",
  "version": 3,
  "updatedAt": "2026-08-15T18:04:22.000Z",
  "schemaVersion": 1,
  "settings": { "windowLayout": "split" }
}

Error responses

StatusCondition
400Invalid appKey or deviceId.
401Not authenticated.
404No device-scoped document exists for this device.

PUT /api/user/app-settings/{appKey}/devices/{deviceId}/settings

Auth required: yes Description: Create or update a device's settings document using body-baseVersion compare-and-swap. The device must be registered first (see register a device); a write to an unregistered device returns 404.

Request body (identical to the account PUT):

{
  "baseVersion": 3,
  "schemaVersion": 1,
  "settings": { "windowLayout": "split" }
}

Response 200 OK: the newly stored document with version incremented and scope: "device".

Response 409 Conflict: same shape as the account PUT, with current carrying the current device document.

Error responses

StatusCondition
400Invalid appKey/deviceId, unparseable JSON, or invalid body.
401Not authenticated.
404The device is not registered.
409baseVersion did not match; body includes current.
413Serialized settings exceeds 64 KB.
429Over the write rate limit.

Device registry

Devices are the machines an app runs on. Each is identified by a client-stable deviceId that the client generates once and keeps (for example in the Keychain). The first device registered for an app becomes the default ("main workstation") automatically; its settings seed a brand-new machine on first run (see bootstrap).

GET /api/user/app-settings/{appKey}/devices

Auth required: yes Description: List the registered devices for this user and app, newest-seen first.

Response 200 OK

{
  "devices": [
    {
      "deviceId": "macbook-pro-9F2C1A7B4D",
      "deviceName": "Studio iMac",
      "platform": "macos",
      "isDefault": true,
      "lastSeenAt": "2026-08-15T18:04:22.000Z",
      "appVersion": "1.2.0",
      "osVersion": "15.5",
      "hasDeviceSettings": true
    }
  ]
}
FieldTypeDescription
deviceIdstringClient-stable device id.
deviceNamestringHuman-friendly name shown in the UI.
platformstringOne of macos, ios, android, windows, linux, web, other.
isDefaultbooleanWhether this is the default ("main workstation").
lastSeenAtstring (ISO 8601)When the device last registered or refreshed.
appVersionstring | nullClient app version reported at registration.
osVersionstring | nullOS version reported at registration.
hasDeviceSettingsbooleanWhether this device has its own device-scoped settings document.

Error responses

StatusCondition
400Invalid appKey.
401Not authenticated.

POST /api/user/app-settings/{appKey}/devices

Auth required: yes Description: Register a new device or refresh an existing one (keyed on deviceId). Registering an existing deviceId refreshes its name, platform, versions, and last-seen time while keeping its default flag. The first device registered for the app becomes the default automatically.

Request body

{
  "deviceId": "macbook-pro-9F2C1A7B4D",
  "deviceName": "Studio iMac",
  "platform": "macos",
  "appVersion": "1.2.0",
  "osVersion": "15.5",
  "appDisplayName": "Visual Introspection"
}
FieldTypeRequiredDescription
deviceIdstringyesClient-stable id (validated against the deviceId format).
deviceNamestringyesDisplay name, 1 to 120 characters (trimmed).
platformstringyesOne of macos, ios, android, windows, linux, web, other.
appVersionstringnoClient app version, up to 120 characters.
osVersionstringnoOS version, up to 120 characters.
appDisplayNamestringnoFriendly app name, up to 120 characters. Used only to seed the shared app catalog entry the first time this appKey is seen; ignored afterward.

Response 200 OK

{
  "device": {
    "deviceId": "macbook-pro-9F2C1A7B4D",
    "deviceName": "Studio iMac",
    "platform": "macos",
    "isDefault": true,
    "lastSeenAt": "2026-08-15T18:04:22.000Z",
    "appVersion": "1.2.0",
    "osVersion": "15.5"
  }
}

The write response does not include hasDeviceSettings (only the list endpoint does).

Error responses

StatusCondition
400Invalid appKey, unparseable JSON, or invalid body (bad deviceId, missing/oversized deviceName, unknown platform).
401Not authenticated.
429Over the write rate limit.

PATCH /api/user/app-settings/{appKey}/devices/{deviceId}

Auth required: yes Description: Rename a device and/or promote it to the default ("main workstation"). At least one of deviceName or isDefault must be present.

Request body

{ "deviceName": "Studio iMac", "isDefault": true }
FieldTypeRequiredDescription
deviceNamestringnoNew display name, 1 to 120 characters (trimmed).
isDefaultbooleannotrue promotes this device to default and demotes the previous default atomically.

Response 200 OK: the updated device (same shape as the POST response device object).

Error responses

StatusCondition
400Invalid appKey/deviceId, unparseable JSON, or a body with neither deviceName nor isDefault.
401Not authenticated.
404No such device.
429Over the write rate limit.

DELETE /api/user/app-settings/{appKey}/devices/{deviceId}

Auth required: yes Description: Deregister a device and delete its device-scoped settings document. If the removed device was the default, the most recently seen remaining device is promoted to default automatically.

Response 200 OK

{ "deleted": true, "promotedDeviceId": "mac-mini-3B7E2F9A1C" }
FieldTypeDescription
deletedbooleanAlways true when the device existed and was removed.
promotedDeviceIdstring | nullThe device promoted to default in its place, or null if the removed device was not the default or no devices remain.

Error responses

StatusCondition
400Invalid appKey or deviceId.
401Not authenticated.
404No such device.
429Over the write rate limit.

First-run bootstrap

GET /api/user/app-settings/{appKey}/bootstrap?deviceId={deviceId}

Auth required: yes Description: Resolve the settings a fresh machine should start from, with provenance. The client passes the new machine's deviceId and receives the resolved document plus a source describing where it came from, so it can seed itself and then PUT its own device document.

Query parameters

ParamTypeRequiredDescription
deviceIdstringyesThe new machine's client-stable id (validated against the deviceId format).

Resolution runs in priority order and returns the first that exists:

  1. self: this device already has its own device document.
  2. default-device: the default ("main workstation") device's document.
  3. account: the shared account document.
  4. none: nothing to seed from (returns 404).

Response 200 OK: the resolved settings document with a source field merged in at the top level (the document fields are not nested under a doc key):

{
  "source": "default-device",
  "appKey": "visual-introspection",
  "scope": "device",
  "deviceId": "mac-mini-3B7E2F9A1C",
  "version": 5,
  "updatedAt": "2026-08-15T18:04:22.000Z",
  "schemaVersion": 1,
  "settings": { "theme": "dark" },
  "defaultDeviceId": "mac-mini-3B7E2F9A1C",
  "defaultDeviceName": "Mac mini"
}

For source: "default-device", the response also includes defaultDeviceId and defaultDeviceName (the source machine). For source: "self" and source: "account", only source plus the document fields are returned. Note that scope and deviceId reflect the source document (for default-device they belong to the default machine, not the requesting deviceId).

Response 404 Not Found (nothing to seed from):

{ "source": "none" }

Error responses

StatusCondition
400Invalid appKey or missing/invalid deviceId.
401Not authenticated.
404Nothing to seed from ({ "source": "none" }).
</invoke>