API keys
API keys (personal access tokens) let third parties use the OpenApp API programmatically without browser-based login. A key acts as the user who created it.
Creating an API key
Section titled “Creating an API key”- Sign in to the dashboard.
- Go to Settings → API keys.
- Click Add, pick Organization for new keys, enter a name and expiration.
- Copy the token immediately — it is shown only once.
You need the api_keys:create role in the organization (see Roles). Over HTTP, send POST /api/v1/api-keys with the X-Org header set to that organization. Each key consumes one unit of the api_keys quota.
{ "name": "Lobby kiosk", "expires_in": "P90D", "scoped_roles": ["entities:set_state"], "scoped_entity_ids": ["01J8Z3NDEKTSV4RRFFQ69G5FAV"]}Using an API key
Section titled “Using an API key”Send the full token in the X-API-Key header. The API root is {origin}/api/v1, where {origin} is the origin embedded in the token:
curl -H "X-API-Key: https://openapp.example.com_openapp_YOUR_SECRET" \ -H "X-Org: YOUR_ORG_ID" \ https://openapp.example.com/api/v1/devicesDo not send an API key in Authorization: Bearer. The gateway reserves that header for JWTs and OAuth access tokens, and rejects an API key there with 401.
Token format
Section titled “Token format”Tokens follow: {origin}_openapp_{secret}
- origin: The deployment’s bare public origin — scheme, host, and optional port, with no path (for example
https://openapp.example.com; production ishttps://openapp.house). Must matchOPENAPP_API_BASE_URLon the server. The SDKs derive the API root{origin}/api/v1from it and reject a token whose origin contains a path. - secret: A random, URL-safe Base64 string.
The full token is shown once at creation. Store it securely.
Expiration
Section titled “Expiration”Every key expires. On create, send exactly one of:
expires_at— an absolute RFC 3339 timestamp, for example2027-01-31T23:59:59Z.expires_in— a duration: ISO 8601 (P90D,PT12H; no months or years) or a compact token (30d,12h,2w;Mmeans minutes).1yis rejected.
Sending both, or neither, is rejected with 400. The expiry must be in the future and at most 3650 days (10 years) away. Expired keys are rejected with 401; create a new key before the old one expires, or extend it with PATCH.
Voice keys (POST /api-keys/voice) default to 365 days and cannot exceed 730 days.
Scoping (least privilege)
Section titled “Scoping (least privilege)”An unscoped key inherits all of its owner’s roles. A scoped key is bounded on every request, whatever its owner holds:
scoped_roles— the only roles the key may exercise. The owner’sadminorplatform_adminsatisfies only roles that are also in this list.scoped_entity_ids— the only entities the key may reach. Entity ids must belong to the key’s organization.
You can only grant roles you hold in the organization. The roles a key may be scoped to are:
entities:set_state, entities:set_hold, entities:read, entities:update, entities:delete, entities:list, entities:create, devices:read, devices:list, integrations:read, integrations:list, integrations:users:list, integrations:users:write, orgs:read, orgs:list, users:read, users:list, zones:read, zones:list.
Any other role string is rejected with 400. Voice keys are always scoped to entities:set_state and one entity. Agent credentials are scoped the same way; the agent’s own allowed_entity_ids is intersected with the key’s entity list, and an agent with no list is not entity-restricted — set one.
Routes a scoped key can reach
Section titled “Routes a scoped key can reach”A scoped key reaches only the routes below (paths relative to /api/v1), and only when its scoped_roles include the role shown. Every other route returns 403, including /me/*, API key and agent management, integration secrets, and OpenApp Scripting.
| Route | Role in scope |
|---|---|
GET /status | none |
POST /mcp | each tool checks its own role |
GET /orgs, GET /orgs/children, GET /orgs/search | orgs:list |
GET /orgs/{id}, GET /orgs/{id}/ancestors, GET /orgs/{id}/permissions | orgs:read |
POST /orgs/{org_id}/resolve | entities:read |
POST /orgs/{org_id}/perception/lpr, POST /orgs/{org_id}/perception/visitor-present | entities:set_state |
GET /orgs/{id}/users, GET /users/search | users:list |
GET /devices | devices:list |
GET /devices/{id} | devices:read |
GET /devices/{device_id}/entities | entities:list |
GET /entities | entities:list |
POST /entities | entities:create |
GET /entities/{id}, GET /entities/{id}/metadata-definition, GET /entities/{id}/hold | entities:read |
PUT /entities/{id}, PATCH /entities/{id} | entities:update |
DELETE /entities/{id} | entities:delete |
PUT /entities/{id}/hold | entities:set_hold |
POST /entities/{id}/actions/{action_id} and .../preview | entities:set_state for switchable.*; entities:set_hold for switchable.set_relay_mode. Other actions are refused. |
GET /integrations | integrations:list |
GET /integrations/{id}, GET /integrations/{id}/entities, GET /integrations/{id}/access-invites | integrations:read |
GET /integrations/{id}/integration-users | integrations:users:list |
POST /integrations/{id}/ops/users_admin.list_users | integrations:users:list |
POST /integrations/{id}/ops/users_admin.remove_user | integrations:users:write |
GET /zones/{id}, GET /zones/{id}/summary | zones:read |
GET /zones, GET /zones/tree | zones:list |
The role in the table admits the request; the handler then runs its normal permission checks, bounded by the same scope.
Keys limited to specific entities
Section titled “Keys limited to specific entities”When scoped_entity_ids is set:
- Routes with an entity
{id}in the path work only for listed entities. - Routes that reach entities or devices not named in the path are refused with 403:
GET /devices,GET /devices/{id},GET /devices/{device_id}/entities,GET /entities,POST /entities,GET /integrations/{id}/entities, andPOST /orgs/{org_id}/resolve. - Hosted MCP and perception routes check each entity they touch against the list.
A key with an entity list but no scoped_roles can do nothing.
Rename or extend
Section titled “Rename or extend”PATCH /api/v1/api-keys/{id} renames a key and optionally changes its expiry:
{ "name": "Lobby kiosk (2027)", "expires_in": "P180D" }nameis required.expires_atorexpires_in(not both) sets a new expiry, which must be in the future. Omit both to keep the current expiry.expires_incounts from now.- Returns 204. Scope cannot be changed; create a new key instead.
You can only update your own keys. A scoped key cannot call this route.
Lifecycle: Revoke vs Delete
Section titled “Lifecycle: Revoke vs Delete”API keys follow a two-stage deactivation model: revoke first, delete (optional) later.
States
Section titled “States”- Active – Key can authenticate API requests.
- Revoked – Key is disabled; any request using it returns 401. The key remains in the database with a
revoked_attimestamp. - Deleted – Key is permanently removed from the database. Only revoked keys can be deleted.
Revoke
Section titled “Revoke”When to use: You suspect a key was exposed, the key is no longer needed, or an employee or service no longer requires access.
Effect: The key stops working immediately. The row stays in the database so you can see when it was revoked. You can restore it later (if not expired) or delete it.
How: Dashboard (Settings → API keys → Revoke) or DELETE /api/v1/api-keys/{id}.
Restore
Section titled “Restore”When to use: A key was revoked by mistake, or you want to re-enable it (for example after a temporary suspension).
Effect: Clears revoked_at; the key works again.
How: Dashboard (Settings → API keys → Restore on a revoked key) or POST /api/v1/api-keys/{id}/restore. Returns 409 if the key is not revoked or is expired.
Delete (purge)
Section titled “Delete (purge)”When to use: You want to remove revoked keys from the database (cleanup, privacy, or storage reduction).
Effect: The key row is permanently removed. This cannot be undone.
How: Dashboard (Settings → API keys → Delete on a revoked key) or DELETE /api/v1/api-keys/{id}/purge. Returns 409 if the key is not revoked.
Summary
Section titled “Summary”| Action | When to use | Reversible? |
|---|---|---|
| Revoke | Key compromised, no longer needed, temporary suspension | Yes – use Restore to re-enable |
| Restore | Undo revoke | N/A – key is active again |
| Delete | Remove revoked key from database | No – row is gone |
Typical flow: Create → use → Revoke (when no longer needed) → Restore (if needed) or Delete (for cleanup).
API reference
Section titled “API reference”Every route below refuses scoped keys (403).
- Create:
POST /api/v1/api-keys– requiresX-Org; returns the token once - Create voice key:
POST /api/v1/api-keys/voice– one entity,entities:set_state - List:
GET /api/v1/api-keys– your keys, active and revoked, paginated - Rename or extend:
PATCH /api/v1/api-keys/{id}–name, optionalexpires_atorexpires_in - Revoke:
DELETE /api/v1/api-keys/{id}– disables the key - Restore:
POST /api/v1/api-keys/{id}/restore– re-enables a revoked key (409 if not revoked or expired) - Delete (purge):
DELETE /api/v1/api-keys/{id}/purge– permanently removes a revoked key (409 if not revoked)
Security
Section titled “Security”- Never commit tokens to source control.
- Use short expiries and extend them, rather than long-lived keys.
- Use scoped keys whenever the key does not need everything you can do.
- Revoke keys that may have been exposed.