Realtime
Call Realtime from your backend
Install @lessly/realtime, publish messages, read history and mint tokens for your end users.
@lessly/realtime is the Node package your backend uses to talk to Realtime. It publishes messages, reads history, mints capability tokens for your users, and carries the shapes for namespaces, grants, presence and webhooks. The current published version is 0.4.1.
It is a server-side package. It carries your product API key, so it must never be bundled into a browser — for the browser see the browser client.
Install and construct
npm install @lessly/realtimeThe package targets Node 22 and above and uses the global fetch. Construct Realtime with your product API key and product id. The SDK builds the base URL for you as {edgeUrl}/{productId}/realtime and sends the key in the X-Api-Key header.
import { Realtime } from '@lessly/realtime'
const realtime = new Realtime({
apiKey: process.env.REALTIME_API_KEY!, // rtk_…
productId: process.env.PRODUCT_ID!,
})| Option | Type | Meaning |
|---|---|---|
apiKey | string | Product API key (rtk_…). Required. |
productId | string | Product id, the first path segment on the public routes. Required. |
edgeUrl | string | Public origin. Defaults to DEFAULT_EDGE_URL. |
timeout | number | Per-request timeout in ms. Defaults to 30000. |
retry | RetryConfig | Retry policy, see below. |
headers | Record<string, string> | Extra headers merged into every request. |
DEFAULT_EDGE_URL is exported and is https://public.lessly.com — production.
.comand.devare separate environments with separate databases, so a key minted on production fails with401 invalid_api_keyagainst a.devorigin. SetedgeUrlonly to point at a non-production environment.
An empty apiKey or productId throws. The message never contains the credential.
The constructed client exposes six resources as readonly properties: messages, tokens, namespaces, grants, presence, webhooks.
What your API key reaches
Your API key reaches the public routes, and those serve these five methods:
| Method | Route |
|---|---|
tokens.issue | POST /tokens/issue |
messages.publish | POST /messages |
messages.history | GET /messages/history |
presence.get | GET /presence |
presence.stats | GET /presence/stats |
The rest of the client — tokens.create, the namespaces, grants and webhooks resources, and presence.enter / update / leave — is outside what an API key reaches. Namespaces, grants, API keys and webhooks are managed from the platform, over MCP or on the Realtime API reference. Presence is entered and updated from the browser client, on the connection that is present.
Publish and read history
publish(channel: string, data: unknown): Promise<PublishMessageResponse>
history(channel: string, query: HistoryQuery): Promise<HistoryGetResponse>publish sends data to every subscriber of channel. The response is { channel, published: true }, plus offset and epoch when the namespace history policy stored the message.
const result = await realtime.messages.publish('chat:room-1', {
text: 'hello',
from: 'ada',
})
// result.offset — pass it to a client so it can resume from herehistory reads back what was sent. The query is either a cursor or a window:
type HistoryQuery =
| { cursor: { offset: string; epoch: string } }
| { lastN?: number; lastMs?: number }const page = await realtime.messages.history('chat:room-1', { lastN: 50 })
if (!page.recovered) {
// the cursor epoch no longer matches, or the entries aged out — resync
}
for (const entry of page.entries) {
// entry.id, entry.ts, entry.offset, and entry.data or entry.ref
}recovered is false when the cursor cannot be honoured; treat that as “start again from a snapshot”, covered in history. An entry carries either an inline data payload or a ref ({ bucket_key, size, content_type }) when the payload was too large to inline.
Mint tokens
Two methods mint, and only one of them is the one you want:
| Method | Mints for | Reachable with a product API key |
|---|---|---|
tokens.issue (Recommended) | One of your end users | Yes |
tokens.create | The calling credential itself | No |
issue takes the subject, the concrete channels and the operations the user may perform on each:
interface IssueTokenInput {
subject: string // 1..128 chars
channels: { name: string; ops: ChannelOp[] }[] // 1..32 concrete channels, no wildcards
ttlSeconds?: number // 60..3600, defaults to 3600 server-side
}
type ChannelOp = 'subscribe' | 'publish' | 'presence' | 'history'const { token, gatewayUrl, expiresAt } = await realtime.tokens.issue({
subject: user.id,
channels: [{ name: 'chat:room-1', ops: ['subscribe', 'history', 'presence'] }],
ttlSeconds: 900,
})The declared operations are narrowed by namespace policy when the token is minted, and a request whose capabilities are stripped entirely fails with 422. Return token and gatewayUrl to the browser; expiresAt is the ISO-8601 expiry. See authentication.
create mints a token for the calling credential itself, optionally scoped to a list of channels, and returns { token, gatewayUrl }. It does not mint for one of your end users.
Retries
Every request goes through the retry policy.
interface RetryConfig {
maxAttempts?: number // default 3
initialDelay?: number // default 500 (ms)
maxDelay?: number // default 5000 (ms)
}A request is retried when it fails with HTTP 429, any status of 500 or above, or a network error (status code 0). Every other failure is thrown immediately.
The delay before the next attempt is initialDelay * 2 ** attempt, capped at maxDelay. When the response carried a Retry-After header, that value wins and the SDK waits exactly that many seconds instead. maxAttempts counts the first attempt, so the default of 3 means one call and at most two retries; the error from the last attempt is thrown.
Errors
Failures throw RealtimeError or one of its subclasses. Every instance carries:
| Property | Type | Meaning |
|---|---|---|
statusCode | number | HTTP status. 0 for a network failure. |
errorType | string | Machine-readable error code parsed from the body. |
retryAfter | number | undefined | Seconds from the Retry-After header; set on rate limits. |
The subclass is chosen by status:
| Status | Class |
|---|---|
| 400 | ValidationError |
| 401 | AuthenticationError |
| 403 | ForbiddenError |
| 404 | NotFoundError |
| 409 | ConflictError |
| 422 | UnprocessableEntityError |
| 429 | RateLimitError |
| 503 | ServiceUnavailableError |
| any other | InternalError |
NetworkError covers a failed or timed-out connection. It has status code 0 and error type Network Error; a timeout reports Request timed out after {timeout}ms.
import { RateLimitError, RealtimeError } from '@lessly/realtime'
try {
await realtime.messages.publish('chat:room-1', { text: 'hello' })
} catch (error) {
if (error instanceof RateLimitError) {
// error.retryAfter is the server's advice, in seconds
} else if (error instanceof RealtimeError) {
console.error(error.statusCode, error.errorType, error.message)
}
}parseErrorBody(status, body) and createErrorFromResponse(status, body, retryAfter?) are exported too, for code that handles raw HTTP responses itself.
Namespaces, grants and webhooks
These are managed from the platform, not with your API key. The shapes are carried by the SDK because the policy they hold decides what your channels allow.
create(input: { name: string } & NamespacePolicyInput): Promise<NamespaceView>
list(): Promise<NamespaceView[]>
get(name: string): Promise<NamespaceView>
update(name: string, patch: NamespacePolicyInput): Promise<NamespaceView>
delete(name: string): Promise<{ deleted: true }>The policy fields, all optional on both create and update:
| Field | Type | Meaning |
|---|---|---|
visibility | 'public' | 'authorized' | Whether any subscriber is allowed, or only authorized ones. |
presence | boolean | Whether a roster is kept for channels in the namespace. |
clientEvents | boolean | Whether connected clients may publish directly. |
history | 'none' | 'last-message' | 'window' | What is retained. |
historyWindowSeconds | number | Retention window when history is window. |
encryptionRequired | boolean | Whether payloads must be encrypted. |
identifiedOnly | boolean | Whether anonymous subjects are refused. |
subscribeProxyUrl | string | null | HTTPS callback consulted per subscribe on authorized namespaces. null clears it. |
NamespaceView returns the resolved policy plus id, name, createdAt and updatedAt.
subscribeProxySecret— the full signing secret for the subscribe proxy — appears only in the response that set or changedsubscribeProxyUrl. Afterwards onlysubscribeProxySecretPrefixis visible, and it isnullwhen no proxy URL is set.
A grant is a durable permission: a subject, a channel pattern and a set of operations. CreateGrantInput is { subject, pattern, ops }, where subject is an identity id or * for every identity in the product, and pattern is a channel pattern such as chat:* in which * matches exactly one segment. GrantView is { id, subject, pattern, ops, createdAt }. The pattern rules are on Realtime.
The webhooks resource carries create, list, get, update, delete, rotateSecret and deliveries. What a delivery looks like and how to verify it is on webhooks.
The presence resource reads a roster with get(channel) and stats(channel); its enter, update and leave methods are not reachable with your API key, because a member enters presence from the browser client, on the connection that is present. See presence.
Next steps
- Connect a browser tab: the other half of the loop.
- Authenticate your backend and your users: what the token you mint can and cannot carry.
- Replay what a client missed: cursors, windows and what a refused recovery means.
- Look up a limit or an error: quotas, status codes and payload sizes.