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

https://api.nolag.app/v1

Authentication

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

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

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

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)
  • 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.
  • 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

GET /apps/{appId}

Update App

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

DELETE /apps/{appId}

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

Reset App to Blueprint

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

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

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)

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

GET /apps/{appId}/rooms/{roomId}

Update Room

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

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

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

GET /apps/{appId}/rooms/{roomId}/actors

Returns a plain array of the room's grants.

Revoke Room Grant

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

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

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

{
  "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.

Get Actor

GET /actors/{actorId}

Update Actor

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

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 for the model.

List Scopes

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

GET /scopes/{scopeId}

Create Scope

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:

{
  "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

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

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

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 for browsers and mobile apps instead of shipping actor access tokens.

List Signing Keys

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

GET /signing-keys/{signingKeyId}

Create Signing Key

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:

{
  "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

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

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:

# 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.

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

{
  "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:

{
  "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:

{
  "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 for status codes and the exact messages.

SDKs

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