Skip to content

Realtime

Authenticate your backend and your users

Hold one product API key on your server, and mint a short-lived token from it for each end user.

Two credentials, two levels. Your backend holds a long-lived product API key. Each of your end users gets a short-lived delegated token, minted by your backend from that key. The key never leaves your servers; the token is the only thing a browser ever sees.

CredentialHeld byLifetimeReaches
Product API key (rtk_…)Your backend onlyUntil you revoke itThe whole public HTTP surface, for one product
Delegated tokenOne end user’s browser60–3600 secondsExactly the channels and operations you named, after namespace narrowing

Create a product API key

A product API key is a machine secret that identifies one product. It is rtk_ followed by 43 characters of URL-safe random data:

rtk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The key is the product’s public key, the one credential that authenticates the product’s public routes, and its scope is narrowed to Realtime. Keys are created, listed and revoked from the platform, using your own Lessly login. Realtime has no key of its own.

MCP. organization_public-keys_create to create one, organization_public-keys_list to see the keys and their scopes, organization_public-keys_revoke to revoke one. organization_public-keys_update-scope replaces the scope of a key that is still active.

REST. Under /governance/api/v1/products/:productId/public-keys, on the Organization API reference.

The secret is shown once, at creation, and never again. Afterwards only a rtk_ plus six characters prefix is retrievable — enough to tell your keys apart, useless as a credential. If you lose a key, revoke it and create another.

A product API key cannot create, list or revoke API keys — including itself.

Where to keep it

  • In your backend’s secret store or environment, alongside your database password.
  • Never in frontend code, a mobile app bundle, a public repository or a browser request. Anything that reaches a user’s device can be read by that user.
  • One key per system that needs one, so revoking a leaked key does not take down everything else. Revocation takes effect immediately.

Send the key

Send the key in the X-Api-Key header on every call to the public HTTP surface, which is rooted at your product:

POST /{productId}/realtime/tokens/issue HTTP/1.1
Host: public.lessly.com
X-Api-Key: rtk_xxxxxx…
Content-Type: application/json

The server SDK does this for you — construct it with apiKey and productId and it builds the same base URL and sends the same header:

import { Realtime } from '@lessly/realtime';

const realtime = new Realtime({
  apiKey: process.env.LESSLY_REALTIME_API_KEY!,
  productId: process.env.LESSLY_PRODUCT_ID!,
});

A key is bound to the product it was created in. Using it against a different product’s path returns 404, not 403 — the surface never confirms that another product exists.

Mint a token for one user

A delegated token is a short-lived credential your backend mints for one of your end users, naming the exact channels that user may use and what they may do there. Your backend is the authority on who its users are; Realtime takes your word for the subject and enforces the rest.

POST /tokens/issue, authenticated with the API key. Through the SDK:

const { token, gatewayUrl, expiresAt } = await realtime.tokens.issue({
  subject: 'user-42',
  channels: [
    { name: 'chat:room-1', ops: ['subscribe', 'history'] },
    { name: 'chat:room-7', ops: ['subscribe'] },
  ],
  ttlSeconds: 900,
});

The request declares:

FieldRules
subjectYour identifier for the end user. 1 to 128 characters of a-z, A-Z, 0-9, ., _ and -. No colons.
channels1 to 32 entries. Each has a name — a concrete channel, no wildcards — and ops, one or more of subscribe, publish, presence, history.
ttlSecondsOptional. 60 to 3600. Defaults to 3600.

The response carries:

FieldMeaning
tokenThe credential to hand to the browser
gatewayUrlThe WebSocket endpoint the browser client connects to
expiresAtISO-8601 timestamp at which the token stops working

What narrowing does to it

What you declare is a ceiling, not a guarantee. Before the token is signed, each channel is checked against its namespace policy:

  • A channel whose namespace is not registered is dropped.
  • Operations the namespace does not allow are dropped — presence without presence enabled, publish without client events enabled, history on a namespace retaining nothing.
  • If everything you declared is dropped, the call fails with 422 rather than returning a token that could connect and then be refused on every action.

Narrowing only ever removes. A token can never carry more than its namespace allows, whatever your backend asks for. The full policy table is on Realtime.

External subjects

Your end users live in a different identity space from Lessly platform users. The subject you pass is stored in the token prefixed with ext:, so user-42 becomes ext:user-42, and the token is marked as belonging to an external subject. The two spaces cannot collide: naming your own user after a Lessly user id borrows none of that user’s authority.

That prefixed subject is what identifies the connection on a channel roster, so it is the value you will see in presence members for tokens minted this way.

Refresh a live connection

A token stops working at expiresAt. Plan for that rather than minting hour-long tokens by default: a shorter TTL means a revoked or reassigned user loses access sooner.

The browser client is built around this. You give it a token provider — a function that calls an endpoint on your backend and returns a fresh token and WebSocket endpoint — and it calls that function on every connection attempt:

import { connect } from '@lessly/realtime-client';

const client = connect({
  tokenProvider: async () => {
    const res = await fetch('/api/realtime/token', { credentials: 'include' });
    return res.json(); // { token, gatewayUrl }
  },
});

Your endpoint decides who the caller is with your own session, then mints for that user and nobody else. A connection whose token has expired is closed by the realtime service; the client fetches a new token and reconnects straight away, with no backoff on that particular case.

To swap the token on a connection that is still open — after the user gains access to another channel, for instance — call auth(). With no argument it asks the token provider for a fresh one:

await client.auth();

The call resolves when the service accepts the new token, and rejects if it does not.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect