Call Realtime from your backend
Use the Realtime server SDK to manage channels, messages, tokens, and webhooks.
Use @lepsto/sdk/realtime/server on your backend to manage namespaces, grants and webhooks, write presence for clients without sockets, issue tokens, publish messages and read history. Management requires a bearer token and an explicit base URL.
Before you start
- Node 20 or later; the SDK uses global
fetch. - A bearer token and management base URL, or a product public key and product ID for the five public routes.
- A registered namespace before publishing or issuing channel tokens.
Examples use a chat application. Replace the environment variables, chat:room-1, user IDs and event IDs with your own values.
Pick the subpath first
Choose the HTTP client for the credential and operations your backend needs:
- With a product public key and product ID, use
@lepsto/sdk/realtimefor public HTTP operations. - With a bearer token and explicit base URL, use
@lepsto/sdk/realtime/serverfor management and the shared operations below.
Keep both kinds of backend credential on the server. A browser uses the socket client with a delegated token, even though that socket shares an import path with the public HTTP client.
If your backend already uses the management client, use it for publishing and reading too. You do not need a second credential for those operations.
Management routes are not served at the public product URL. For example, public.lepsto.com/{productId}/realtime/namespaces does not exist; management construction needs the explicit API base URL.
Install and construct
npm install @lepsto/sdk-
Install
@lepsto/sdkwith the command above. The server client is part of that package and requires Node 20 or later. -
Choose one authentication form below. Both
RealtimeandcreateRealtimeServer(...)accept them. Combining a bearer token withapiKeythrowscannot combine a bearer token with apiKey — pick one auth mode.
With a bearer token and an explicit base URL
Use this form for management operations:
import { Realtime } from '@lepsto/sdk/realtime/server'
const admin = new Realtime(process.env.REALTIME_TOKEN!, {
baseUrl: process.env.REALTIME_BASE_URL!,
})The client sends Authorization: Bearer <token>. Empty tokens or base URLs throw without including the credential in the error message.
RealtimeServerOptions:
Option | Type | Meaning |
|---|---|---|
baseUrl | string | Base URL of the Realtime API. Required. |
plane | 'browser' | 'server' | Where this client runs. Leave it unset on a server; 'browser' makes the runtime refuse a server credential outright. |
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. |
With a product public key
This form calls the public edge. It builds {edgeUrl}/{productId}/realtime and sends Authorization: Bearer <apiKey>. Management methods remain unavailable with this credential:
const realtime = new Realtime({
apiKey: process.env.REALTIME_API_KEY!, // lpk_… , or an older rtk_… key
productId: process.env.PRODUCT_ID!,
})Option | Type | Meaning |
|---|---|---|
apiKey | string | Product public key — lpk_…, or an rtk_… key issued before the migration. Required. |
productId | string | Product id, the first path segment on the public edge. Required. |
edgeUrl | string | Public edge origin. Defaults to DEFAULT_EDGE_URL. |
plane | 'browser' | 'server' | As above. |
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. |
Empty apiKey or productId values throw; the error omits the credential.
For a backend that needs only public operations, @lepsto/sdk/realtime is the usual choice. This constructor lets an existing server SDK integration also use a product public key for those operations.
Both forms expose six readonly resources: messages, tokens, namespaces, grants, presence and webhooks. Which calls succeed depends on the credential.
Two paths to one action
Publishing, history and presence reads are available through both imports:
@lepsto/sdk/realtime HTTP client | @lepsto/sdk/realtime/server | |
|---|---|---|
Client | Generated public operations | Server client with public and management methods |
Credential | Product public key | Bearer token or product public key |
Host | public.lepsto.com/{productId}/realtime | Explicit baseUrl, or product edge URL in public-key mode |
Use with these credentials | Backend only | Backend only |
Keep using the client your backend already needs. Their shared operations do not require switching authentication modes.
For presence, the server client supplies both administrative writes (enter, update, leave) and reads (get, stats). Only the reads are available through the public import.
The access boundary
A bearer token with the required permissions can reach the management methods at your base URL. A product public key reaches only these five public routes:
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 |
tokens.create, namespaces, grants, webhooks and the presence writes require management access. The workspace provides Realtime → Namespaces, Grants and Webhooks for the corresponding records.
This SDK does not create, list or revoke product public keys. Owners and Admins manage those in Product → Settings → Public Access.
Publish and read history
publish(
channel: string,
data: unknown,
opts?: { retry?: RetryConfig; idempotencyKey?: string },
): Promise<PublishMessageResponse>
history(channel: string, query: HistoryQuery): Promise<HistoryGetResponse>- Publish to a registered channel. The response contains
{ channel, published: true }, withoffsetandepochwhen history retains the message. Deployments supporting publish idempotency also returnmessageIdandreplayed.
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 hereRead retained messages with messages.history, using 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
}When recovered is false, reload current state from your application’s source of truth; see history. Entries contain inline data or a ref with bucket_key, size and optional content_type for stored payloads.
Mint tokens
- Select the token method for the identity you are authorizing:
Method | Mints for | Reachable with a product public key |
|---|---|---|
tokens.issue (Recommended) | One of your end users | Yes |
tokens.create | The calling credential itself | No |
For an end user, pass issue their subject, concrete channels and allowed operations:
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,
})Policy removes operations the namespace does not allow. If none remain, issuance fails with 422. Return the issued token response to the browser’s connectRealtime provider. See authentication.
tokens.create authorizes the calling credential itself, optionally narrowed to channels, and returns { token, gatewayUrl }. It requires management credentials. Use tokens.issue when authorizing your application’s end users.
Verify it worked
- With the bearer-mode client above, call
await admin.namespaces.list()and confirm your namespace is listed. For401or403, check the credential and permissions; public-key mode cannot list namespaces. - Publish a message to a namespace with history enabled. Check for
offsetandepoch, then find the message withmessages.history. - Issue an end-user token, connect a browser and subscribe to an allowed channel. Confirm delivery. A
422during issuance means policy removed every requested capability.
Retries
This server subpath enables retries explicitly. The shared SDK runtime otherwise defaults to no retries. Its retry options are:
interface RetryConfig {
maxAttempts?: number // default 3
initialDelay?: number // default 500 (ms)
maxDelay?: number // default 5000 (ms)
}Requests retry on HTTP 429, statuses 500 and above, and transport failures. Other errors are thrown immediately, except for the publish-specific conflict below.
Delays start at initialDelay and double as initialDelay * 2 ** attempt, capped by maxDelay. A Retry-After header overrides the delay with its value in seconds. maxAttempts includes the first request: the default allows two retries after it. If all attempts fail, the last error is thrown.
DEFAULT_SERVER_RETRY, DEFAULT_TIMEOUT_MS and DEFAULT_EDGE_URL are exported.
messages.publish mints its own idempotency key
messages.publish creates an Idempotency-Key so retries do not publish the same call twice. The service deduplicates by (productId, channel, key) for 24 hours and returns the first result if a response was lost. It uses the same retry defaults: three attempts, a 500 ms initial delay and a 5 s cap.
The SDK reuses one key for every attempt of a call.
For work that may be retried outside this SDK call, such as a queued event, supply your event’s stable ID:
await realtime.messages.publish('chat:room-1', data, { idempotencyKey: myEventId })Reusing that event ID deduplicates later queue redeliveries within the 24-hour window. Supplied keys are sent unchanged; the service validates them and returns 400 for an invalid key.
Publishing has two additional retry rules:
409 idempotency_key_in_flightmeans a publish using this key is still running. The SDK retries after the service’sRetry-After: 1.422 idempotency_key_reusemeans the key was reused with a different body. Correct the caller; it is not retried. Neither is400. A503is safe to retry because an unavailable deduplication store rejects the publish instead of accepting it without deduplication.
replayed: true identifies a stored response from the idempotency window; messageId identifies the message. Both fields remain optional in PublishMessageResponse for compatibility with older deployments.
Errors
API failures use LepstoApiError, exported from @lepsto/sdk 0.5.0 and later. Inspect the common fields below rather than looking for toolkit-specific status subclasses.
Property | Type | Meaning |
|---|---|---|
status | number | HTTP status. 0 when the SDK refused to send the request at all. |
code | string | undefined | Machine-readable code. What to branch on. |
body | unknown | The service’s response body, verbatim. |
serverMessage | string | undefined | The message the service sent, when it sent one. |
retryAfter | number | undefined | Seconds from the Retry-After header; set on rate limits. |
environmentHint | string | undefined | On 401 and 403: which environment answered. |
Use status and code to choose your error handling:
import { LepstoApiError } from '@lepsto/sdk'
try {
await realtime.messages.publish('chat:room-1', { text: 'hello' })
} catch (error) {
if (error instanceof LepstoApiError) {
// error.retryAfter is the service's advice, in seconds, on a 429
console.error(error.status, error.code, error.serverMessage)
}
}On 401 and 403, environmentHint helps identify a production/development mismatch.
Timeouts use LepstoTimeoutError. A timed-out non-idempotent request may have been applied, so distinguish it from an explicit rejection before deciding to repeat it.
Namespaces, grants and webhooks
Use a bearer token and explicit base URL for these methods. The same namespace, grant and webhook records are available in the workspace’s Realtime section. Namespace methods are:
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 }>You can pass these optional policy fields on creation or 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 contains the resolved policy and id, name, createdAt and updatedAt.
Use grants.create(input), grants.list(filter?) and grants.revoke(id) to manage durable permissions. List can filter by subject; revoke returns { revoked: true }. CreateGrantInput contains { subject, pattern, ops }. The subject is an identity ID or * for all product identities; a pattern such as chat:* matches exactly one segment per *. GrantView adds id and createdAt. See Realtime for pattern rules.
Manage outbound notifications with webhooks.create, list, get, update, delete, rotateSecret and deliveries:
createtakes{ url, events, description? }and answers{ webhook, secret }, wheresecretis the full signing secret and is returned exactly once, at creation.updateacceptsurl,events,description(nullclears it) andactive. An inactive webhook receives no deliveries.rotateSecretreturns the newsecret— again only once — itsprefix, andpreviousExpiresAt, the moment the rotated-out secret stops verifying. Until then both secrets verify, so a receiver can be updated without dropping deliveries.WebhookViewnever contains a secret. It listssecretsas metadata only:{ prefix, expiresAt, createdAt }, whereexpiresAtisnullfor the current signer and theprefixmatches theX-Realtime-Keyheader on a delivery.deliveriesreturns the recent attempts, newest first, each as{ id, eventType, eventId, status, attempts, responseStatus, lastError, lastAttemptAt, createdAt }withstatusone ofpending,succeeded,failed.
See webhooks for delivery payloads and signature verification.
Presence, including for something with no tab
get(channel: string): Promise<PresenceGetResponse>
stats(channel: string): Promise<PresenceStatsResponse>
enter(channel: string, opts?: PresenceWriteOptions): Promise<PresenceMemberResponse>
update(channel: string, opts?: PresenceWriteOptions): Promise<PresenceMemberResponse>
leave(channel: string, opts?: { memberId?: string }): Promise<PresenceLeaveResponse>get returns one member per identity as { identity, connections, info?, ts }; stats returns the distinct-identity count. The public import also exposes these reads.
Use the three write methods with a bearer token for a bot, job, service account or user without a live socket. Product public keys cannot call them. A connected browser should enter and update presence through its own socket. See presence.
Next steps
- Connect a browser tab: the other half of the loop, and the other subpath.
- 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.
- Call the public HTTP routes: the same five routes without the SDK.
- Look up a limit or an error: quotas, status codes and payload sizes.