---
title: API Reference
description: Complete REST API reference for NoLag. Manage apps, rooms, actors, scopes, and signing keys programmatically.
---

# REST API Reference

Complete REST API reference for managing NoLag programmatically. API keys are project-scoped, so no organization or project IDs are needed in the URLs.

## Base URL

```bash [Terminal]
https://api.nolag.app/v1
```

## Authentication

All API requests require a project-scoped API key in the Authorization header:

```bash [Terminal]
Authorization: Bearer nlg_live_xxx.secret
```

API keys are created in the NoLag dashboard and are scoped to a specific project. The API key determines which project's resources you can access. Requests with a key that is not project-scoped return `403` with the message `API key must be project-scoped to access this endpoint`.

Request bodies are validated strictly: a field that is not listed for an endpoint is rejected with `400`, not ignored.

## Apps

Apps are containers for rooms and topics within your project.

### List Apps

```bash [Terminal]
GET /apps

# Query parameters (optional)
?page=1&limit=20&orderBy[]=name:ASC&name=chat&blueprintId=xxx&status=active
```

Returns a paginated list of apps in your project. Query parameters: `page` (default `1`), `limit` (default `10`, clamped to `1`-`100`), `orderBy` (repeatable, array syntax `orderBy[]=field:ASC` or `orderBy[]=field:DESC`; a scalar `orderBy=name:ASC` fails validation), `name` (partial match), `blueprintId`, `status` (`active`, `disabled`, `suspended`).

### Create App

```bash [Terminal]
POST /apps

{
  "name": "My Chat App",
  "description": "A real-time chat application",
  "blueprintId": "nolag-chat-sdk",
  "slug": "my-chat-app",
  "topics": ["messages", "typing"],
  "topicConfigs": {
    "messages": {
      "logging": { "enabled": true },
      "webhooks": {
        "onSubscribe": { "url": "https://api.example.com/hydrate" }
      }
    }
  },
  "triggerWebhook": { "url": "https://api.example.com/on-publish", "headers": { "Authorization": "Bearer xxx" } }
}
```

Returns `201 Created` with the app.

Required fields: `name`.

Optional fields:

- `description`
- `slug` (auto-generated from `name` if omitted; lowercase alphanumeric and hyphens)
- `blueprintId` and `blueprintVersion` (semver; defaults to the latest version of the blueprint)
- `config` (app configuration object; `config.autoProvisionRooms: true` enables [Ensure Room](#ensure-room-idempotent))
- `topics` (array of topic names, `[a-zA-Z0-9_:-]`)
- `topicConfigs` (per-topic settings, keyed by topic name). Each entry accepts `logging: { enabled, retention?, replayEnabled?, maxReplayMessages? }` where `retention` is one of `"tier"`, `"1d"`, `"7d"`, `"30d"`, `"90d"`, and `webhooks: { onSubscribe?, onPublish? }` where each webhook is `{ url, headers? }`. Note that `logging` is an object, not a boolean.
- `hydrationWebhook` and `triggerWebhook` (app-level webhooks, each `{ url, headers? }`). They apply to every topic in the app; a `topicConfigs.<topic>.webhooks.onSubscribe` or `onPublish` entry overrides them for that topic. See [Webhooks](/docs/concepts/webhooks).
- `dependencies`, `files`, `framework` (app builder fields for apps that carry source code; not needed for the broker)

Creating an app past your plan's app limit returns `403`.

**The stored slug is never the slug you sent.** NoLag appends four random hex
characters to every app slug to keep it unique within the project, whether you
supplied one or not. A request with `"slug": "my-chat-app"` is stored as something
like `my-chat-app-a3f9`.

Read `slug` from the response and use that value everywhere afterwards, including
`setApp()` and topic patterns. Room slugs behave differently: they are stored
exactly as supplied.

### Get App

```bash [Terminal]
GET /apps/{appId}
```

### Update App

```bash [Terminal]
PATCH /apps/{appId}

{
  "name": "Updated Name",
  "description": "Updated description",
  "status": "active"
}
```

Updatable fields: `name`, `slug` (stored as sent; no suffix is added on update, and the app's topic addresses change with it), `description`, `status` (`"active"` | `"disabled"`), `config`, `dependencies`, `files`, `topics`, `topicConfigs` (including per-topic `logging` and `webhooks`), `pinnedBlueprintVersion` (boolean), `hydrationWebhook`, `triggerWebhook`.

### Delete App

```bash [Terminal]
DELETE /apps/{appId}
```

Returns `200 OK` with `{ "success": true }`.

### Reset App to Blueprint

```bash [Terminal]
POST /apps/{appId}/reset-to-blueprint
```

Resets the app's configuration to match its source blueprint. The app must have been created from a blueprint, otherwise the call returns `404`. Returns the updated app.

## Rooms

Rooms organize topics within an app. Each room has a unique slug used in topic patterns.

### List Rooms

```bash [Terminal]
GET /apps/{appId}/rooms
```

Returns a plain array containing every room in the app. The endpoint is not paginated and takes no query parameters.

### Create Room

```bash [Terminal]
POST /apps/{appId}/rooms

{
  "name": "General Chat",
  "slug": "general",
  "description": "General discussion room",
  "topics": ["messages", "typing", "presence"],
  "metadata": {
    "maxUsers": 100
  }
}
```

Returns `201 Created` with the room.

Required fields: `name`. Optional fields: `slug` (auto-generated from name if omitted), `description`, `topics`, `metadata` (arbitrary JSON object), `enableWebRTC` (boolean; adds the `webrtc:offer`, `webrtc:answer`, `webrtc:candidate` and `webrtc:state` topics to the room).

When `topics` is omitted or empty the room inherits the app's topic list. Note that authorization is resolved against the app's topics, not the room's, so a topic must be on the app to be usable in any of its rooms.

A duplicate slug within the app returns `409` with the message `Room with slug "<slug>" already exists`.

**Rooms must exist before a client uses them.** The broker never creates rooms
implicitly. Subscribing or publishing to a room that has not been created returns an
`unknown_topic` error (code `42940`) rather than delivering messages. Unlike app slugs, a room
slug is stored exactly as you supply it.

### Ensure Room (Idempotent)

```bash [Terminal]
POST /apps/{appId}/rooms/ensure

{
  "name": "Matter 4821",
  "slug": "matter-4821",
  "topics": ["messages"]
}
```

Create-if-not-exists, for runtime per-entity rooms such as one room per document,
matter, or device id. Returns `200 OK` with the existing room unchanged when the
slug already matches, so it is safe to call on every entity creation.

Requires the app to have `config.autoProvisionRooms` set to `true`, otherwise the call
returns `403`. Auto-provisioned rooms are capped at 1000 per app; past the cap the
call returns `400` with the message `App <appId> has reached its room cap (1000)`.
Accepts the same body as Create Room.

### Get Room

```bash [Terminal]
GET /apps/{appId}/rooms/{roomId}
```

### Update Room

```bash [Terminal]
PATCH /apps/{appId}/rooms/{roomId}

{
  "name": "Updated Room Name",
  "description": "Updated description",
  "status": "active"
}
```

Updatable fields: `name`, `description`, `status` (`"active"` | `"disabled"`), `topics`, `metadata`. Note: `slug` cannot be changed after creation.

### Delete Room

```bash [Terminal]
DELETE /apps/{appId}/rooms/{roomId}
```

Returns `204 No Content` with an empty response body.

Note: Only dynamic rooms can be deleted. Static rooms defined in blueprints cannot be deleted; the call returns `400`.

## Room Access

Room-level ACL grants control which actors the broker admits to a room. See
[Access Control](/docs/concepts/acl) for the model.

**A room with no grants inherits the app's access mode, which is open by default.**
In practice that means every actor in the project can publish and subscribe until
you add the first grant. Creating that first grant makes the room private, and from
then on only actors with an explicit, unexpired grant are admitted. Adding one grant
therefore removes access from everyone else, so provision all of a room's actors
together.

### Grant Actor Access

```bash [Terminal]
POST /apps/{appId}/rooms/{roomId}/actors

{
  "actorTokenId": "019fd987-a03a-70cf-9420-60538a186bbb",
  "permission": "pubSub",
  "topics": ["messages", "typing"],
  "expiresAt": "2026-12-31T23:59:59Z"
}
```

Required: `permission`, one of `subscribe`, `publish`, or `pubSub`, plus exactly one
of `actorTokenId` (grant a single actor) or `actorType` (grant every actor of that
type, for example `"agent"`).

Optional: `topics` to limit the grant to named topics rather than the whole room,
`expiresAt` (ISO 8601) for a time-limited grant, `isActive` (defaults to `true`),
`role` as a display label, and `metadata` (arbitrary JSON object).

Returns `201 Created`. A referenced `actorTokenId` must belong to the same project
as the API key, otherwise the call returns `404`. An actor (or actor type) that
already holds a grant on the room returns `409`; revoke the existing grant first.

### List Room Grants

```bash [Terminal]
GET /apps/{appId}/rooms/{roomId}/actors
```

Returns a plain array of the room's grants.

### Revoke Room Grant

```bash [Terminal]
DELETE /apps/{appId}/rooms/{roomId}/actors/{roomActorAccessId}
```

Returns `204 No Content`. Removing the last grant returns the room to open access.

## Actors

Actors represent clients that connect to NoLag (devices, users, services, sessions, agents, orchestrators, or observers). Each actor has an access token used for WebSocket connections.

**Important:** The access token is only returned when creating an actor. Save it immediately!

### List Actors

```bash [Terminal]
GET /actors
```

Returns a plain array containing every actor in the project, newest first. The endpoint is not paginated and takes no query parameters.

### Create Actor

```bash [Terminal]
POST /actors

{
  "name": "Web Client",
  "actorType": "device",
  "metadata": {
    "platform": "web"
  }
}
```

Returns `201 Created`.

Required fields: `name`, `actorType`. Optional fields: `expiresAt` (ISO 8601), `metadata`, `accessScopeId` (bind the actor to an [access scope](/docs/scopes); the scope must belong to the same project, otherwise the call returns `400`).

Actor types:

- `device` - Browser, mobile app, IoT device
- `user` - Authenticated user connection
- `service` - Backend service, microservice
- `session` - Browser session, temporary connection
- `agent` - Autonomous LLM-powered connection
- `orchestrator` - Coordination actor that dispatches work across agents
- `observer` - Read-only audit or monitoring connection

Only `agent` and `orchestrator` actors hold a persistent session across disconnects.

Response:

```json [JSON]
{
  "actorTokenId": "019fd987-a03a-70cf-9420-60538a186bbb",
  "projectId": "019e8d1e-a59b-748b-9f69-dd03a91400f8",
  "accessScopeId": null,
  "keyId": "at_live_...",
  "name": "Web Client",
  "actorType": "device",
  "accessToken": "...",
  "status": "active",
  "expiresAt": null,
  "lastUsedAt": null,
  "metadata": null,
  "createdAt": "2026-08-07T00:03:48.000Z"
}
```

**The actor's id field is `actorTokenId`.** There is no field literally named
`actorId` in any response. The `{actorId}` path parameter in the endpoints below
accepts either the `actorTokenId` UUID or the `keyId` (`at_live_...`), so you can
manage an actor from whichever identifier you have. Any other value returns `400`.

`keyId` is the actor's public identifier and is also the `sub` claim when minting
[client tokens](/docs/client-tokens).

### Get Actor

```bash [Terminal]
GET /actors/{actorId}
```

### Update Actor

```bash [Terminal]
PATCH /actors/{actorId}

{
  "name": "Updated Name",
  "status": "active",
  "metadata": {
    "platform": "mobile"
  }
}
```

Updatable fields: `name`, `status` (`"active"` | `"disabled"`), `expiresAt`, `metadata`, `accessScopeId` (a scope UUID from the same project, or `null` to clear the scope). `actorType` cannot be changed; delete and recreate the actor instead.

A scope change takes effect at the actor's next connection, or within about 10 minutes for a live connection when the broker revalidates it.

### Delete Actor

```bash [Terminal]
DELETE /actors/{actorId}
```

Returns `204 No Content` with an empty response body.

## Scopes

Access scopes isolate tenants within a project. An actor bound to a scope can only reach addresses inside that scope; the broker inserts the scope slug into the topic address on the actor's behalf, so client code is unchanged. See [Access Scopes](/docs/scopes) for the model.

### List Scopes

```bash [Terminal]
GET /scopes

# Query parameters (optional)
?page=1&limit=20&slug=acme&name=Acme&isActive=true
```

Returns a paginated list of scopes. Query parameters: `page`, `limit`, `orderBy[]`, `slug`, `name`, `isActive` (`"true"` or `"false"`).

### Get Scope

```bash [Terminal]
GET /scopes/{scopeId}
```

### Create Scope

```bash [Terminal]
POST /scopes

{
  "slug": "acme",
  "name": "Acme Corporation",
  "description": "Isolation scope for the Acme tenant",
  "metadata": { "tenantId": "tenant-123" }
}
```

Returns `201 Created`.

Required fields: `slug` (lowercase alphanumeric and hyphens, no leading or trailing hyphen, up to 100 characters; immutable after creation because it appears in every topic address the scope's actors resolve to) and `name`. Optional fields: `description`, `metadata`.

A duplicate slug within the project returns `409`.

Response:

```json [JSON]
{
  "accessScopeId": "01939f83-8b57-7c3e-a456-426614174000",
  "projectId": "01939f83-8b57-7c3e-a456-426614174001",
  "slug": "acme",
  "name": "Acme Corporation",
  "description": "Isolation scope for the Acme tenant",
  "metadata": { "tenantId": "tenant-123" },
  "isActive": true,
  "createdAt": "2026-01-15T10:30:00.000Z",
  "updatedAt": "2026-01-15T10:30:00.000Z"
}
```

### Update Scope

```bash [Terminal]
PATCH /scopes/{scopeId}

{
  "name": "Acme Corp",
  "isActive": false
}
```

Updatable fields: `name`, `description`, `metadata`, `isActive`. `slug` cannot be changed.

Setting `isActive` to `false` does not unscope the actors bound to the scope: they are denied at their next connection and disconnected at the broker's next revalidation, with the reason `scope_inactive`.

### Delete Scope

```bash [Terminal]
DELETE /scopes/{scopeId}
```

Returns `204 No Content`. Deleting a scope that still has actors assigned returns `409`; move or delete those actors first (`PATCH /actors/{actorId}` with `"accessScopeId": null` clears the binding).

### List Actors in Scope

```bash [Terminal]
GET /scopes/{scopeId}/actors
```

Returns a plain array of every actor bound to the scope, newest first.

## Signing Keys

Signing keys let your backend mint short-lived [client tokens](/docs/client-tokens) for browsers and mobile apps instead of shipping actor access tokens.

### List Signing Keys

```bash [Terminal]
GET /signing-keys

# Query parameters (optional)
?page=1&limit=20&name=backend&status=active
```

Returns a paginated list of signing keys. Query parameters: `page`, `limit`, `orderBy[]`, `name` (partial match), `status` (`active` | `disabled`). The secret is never returned by list or get.

### Get Signing Key

```bash [Terminal]
GET /signing-keys/{signingKeyId}
```

### Create Signing Key

```bash [Terminal]
POST /signing-keys

{
  "name": "Production backend",
  "environment": "live"
}
```

Returns `201 Created`.

Required fields: `name`. Optional fields: `environment` (`"live"` (default) or `"sandbox"`; decides the `keyId` prefix).

Response:

```json [JSON]
{
  "signingKeyId": "01939f83-8b57-7c3e-a456-426614174001",
  "projectId": "01939f83-8b57-7c3e-a456-426614174002",
  "keyId": "sk_live_abc123def456",
  "name": "Production backend",
  "status": "active",
  "lastUsedAt": null,
  "createdAt": "2026-01-15T10:30:00.000Z",
  "signingKey": "sk_live_abc123def456.dGVzdHNlY3JldGZvcmV4YW1wbGVwdXJwb3Nlcw"
}
```

**`signingKey` is returned once.** Only an encrypted copy is stored, so save it
immediately (for example as `NOLAG_SIGNING_KEY` on your backend). `keyId` is public
and goes in the JWT header as `kid`.

### Update Signing Key

```bash [Terminal]
PATCH /signing-keys/{signingKeyId}

{
  "name": "Production backend (rotated)",
  "status": "disabled"
}
```

Updatable fields: `name`, `status` (`"active"` | `"disabled"`). Any status other than `active` stops client tokens signed with the key from verifying. The secret cannot be changed; rotate by creating a new key and deleting the old one.

### Delete Signing Key

```bash [Terminal]
DELETE /signing-keys/{signingKeyId}
```

Returns `204 No Content`. Client tokens signed with the deleted key stop verifying within 60 seconds.

## Topic Patterns

Topics follow the pattern `app-slug/room-slug/topic-name`:

```bash [Terminal]
# Example topic patterns
my-chat-app-a3f9/general/messages
my-chat-app-a3f9/general/typing
my-chat-app-a3f9/private-room/notifications
```

The app slug is the suffixed value returned when the app was created. Topic names are single tokens (`[a-zA-Z0-9_:-]`, no `/`).

## Response Format

Successful responses return the resource directly. The Apps, Scopes and Signing Keys list endpoints are paginated, wrapping results in a `data` array with a `pagination` object. The Rooms, Room Grants, Actors and Actors-in-Scope list endpoints return plain arrays with every record.

```json [JSON]
// Single resource (e.g. GET /apps/{appId})
{
  "appId": "01939f83-8b57-7c3e-a456-426614174000",
  "name": "My App",
  "slug": "my-app-a3f9",
  "description": "...",
  "status": "active",
  "createdAt": "2024-01-01T00:00:00Z"
}

// Paginated list (Apps, Scopes, Signing Keys)
{
  "data": [...],
  "pagination": {
    "total": 100,
    "page": 1,
    "pageCount": 5
  }
}

// Plain array (Rooms, Actors, grants, actors in scope)
[
  { "roomId": "...", "name": "General", ... },
  { "roomId": "...", "name": "Private", ... }
]
```

Error responses come in two shapes. Errors raised by NoLag's own checks (unknown
resource, duplicate slug, invalid identifier, bad credentials) carry a unique `id`
you can quote to support:

```json [JSON]
{
  "id": "01939f83-8b57-7c3e-a456-426614174000",
  "message": "Room with slug \"general\" already exists",
  "timestamp": "2024-01-15T10:30:00.000Z"
}
```

Framework-level errors (a key that is not project-scoped, an app that is not in
your project, a plan limit) use the standard NestJS shape:

```json [JSON]
{
  "statusCode": 403,
  "message": "API key must be project-scoped to access this endpoint",
  "error": "Forbidden"
}
```

Body validation failures return `400` with one entry per failed constraint:

```json [JSON]
{
  "message": "Validation failed",
  "errors": [
    { "path": "slug", "value": "My Slug", "message": "slug must be lowercase alphanumeric with hyphens (no leading/trailing hyphens)" }
  ]
}
```

Read `message` in every case; only the first shape has `id` and `timestamp`. See [Error Reference](/docs/api-reference/errors) for status codes and the exact messages.

## SDKs

For most use cases, we recommend using our official SDKs which wrap this API:

- [JavaScript/TypeScript](/docs/sdks/javascript)
- [Python](/docs/sdks/python)
- [Go](/docs/sdks/go)
