Skip to content

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/realtime for public HTTP operations.
  • With a bearer token and explicit base URL, use @lepsto/sdk/realtime/server for 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
  1. Install @lepsto/sdk with the command above. The server client is part of that package and requires Node 20 or later.

  2. Choose one authentication form below. Both Realtime and createRealtimeServer(...) accept them. Combining a bearer token with apiKey throws cannot 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>
  1. Publish to a registered channel. The response contains { channel, published: true }, with offset and epoch when history retains the message. Deployments supporting publish idempotency also return messageId and replayed.
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 here

Read 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

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

  1. With the bearer-mode client above, call await admin.namespaces.list() and confirm your namespace is listed. For 401 or 403, check the credential and permissions; public-key mode cannot list namespaces.
  2. Publish a message to a namespace with history enabled. Check for offset and epoch, then find the message with messages.history.
  3. Issue an end-user token, connect a browser and subscribe to an allowed channel. Confirm delivery. A 422 during 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_flight means a publish using this key is still running. The SDK retries after the service’s Retry-After: 1.
  • 422 idempotency_key_reuse means the key was reused with a different body. Correct the caller; it is not retried. Neither is 400. A 503 is 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:

  • create takes { url, events, description? } and answers { webhook, secret }, where secret is the full signing secret and is returned exactly once, at creation.
  • update accepts url, events, description (null clears it) and active. An inactive webhook receives no deliveries.
  • rotateSecret returns the new secret — again only once — its prefix, and previousExpiresAt, the moment the rotated-out secret stops verifying. Until then both secrets verify, so a receiver can be updated without dropping deliveries.
  • WebhookView never contains a secret. It lists secrets as metadata only: { prefix, expiresAt, createdAt }, where expiresAt is null for the current signer and the prefix matches the X-Realtime-Key header on a delivery.
  • deliveries returns the recent attempts, newest first, each as { id, eventType, eventId, status, attempts, responseStatus, lastError, lastAttemptAt, createdAt } with status one of pending, 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

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select