The public HTTP routes
The five Realtime routes a backend can call without the SDK: the base path, the key they need, CORS, the body and rate limits, and each route's request and response.
Five HTTP routes your backend can call directly, without the server SDK. They cover the whole server-side job: minting a capability token for one of your end users, publishing a message, reading history, and reading a presence roster.
Everything else — namespaces, grants, webhooks and the archive — is not on this perimeter. You manage those through the @lessly/realtime SDK, or in the workspace under Realtime → Namespaces, Grants, Webhooks and Archive. The key these routes need is platform surface, managed on the Product → Settings → Public Access page.
Base path
https://public.lessly.com/{productId}/realtime{productId} is the id of your product on the platform. Every path below is relative to that base, so a publish is POST https://public.lessly.com/{productId}/realtime/messages.
Authentication
Send your product public key as a bearer credential:
Authorization: Bearer lpk_…A key created on the Product → Settings → Public Access page is lpk_ followed by 32 hexadecimal characters. A key Realtime issued before — the rtk_ form — is the same credential to the edge and keeps working unchanged; only the header it travels in has moved.
X-Api-Key is still accepted with the same value, so an integration written against the older header does not break. Authorization wins when both are present, and is what new code should send.
The key is checked at the platform edge before the request reaches Realtime.
| Status | code | When |
|---|---|---|
401 | public_key_required | No key was presented |
401 | public_key_invalid | The key matches no active key of the product in the path — unknown, mistyped, revoked (once the revocation has propagated), or belonging to another product |
403 | public_key_scope | The key is active but its scope does not cover this route — or it was revoked moments ago and the revocation has not propagated yet |
503 | public_config_unavailable | No key snapshot is loaded and the edge fails closed. Retry with backoff |
The body is {"error": {"code": …, "message": …}}; the three key outcomes carry "step": "public_key_check" beside it. Every rejected key is the same public_key_invalid, so the surface cannot be used to probe for other products.
Key changes reach the edge through a snapshot refreshed roughly every 30 seconds, so a create or a revoke lands within about that window rather than at once. Keys are also per environment: one created for public.lessly.com is unknown on public.lessly.dev and reads there as public_key_invalid, with nothing in the response naming the environment that answered. See authenticate your backend and your users.
This key is a backend credential. Never ship it to a browser. Browsers get a capability token from
POST /tokens/issueinstead.
CORS
Browser requests are accepted only from https://*.lessly.dev. Your own application origins are not on that list, which is deliberate: these routes are for your backend, and the key they need must never reach a browser.
Body size and rate limits
| Limit | Value |
|---|---|
Request body, all routes except POST /messages | 64 KiB (65 536 bytes) |
Request body, POST /messages | 256 KiB (262 144 bytes) |
| Requests | 100 per minute, per calling IP address |
POST /messages has the larger limit because payloads are inlined up to 256 KiB. Publish quotas are separate from the request rate limit and are covered in limits and errors.
POST /tokens/issue
Mints a short-lived capability token for one of your own end users, and tells you where to connect it.
{
"subject": "user-8412",
"channels": [
{ "name": "chat:room-1", "ops": ["subscribe", "history"] },
{ "name": "chat:room-1:typing", "ops": ["subscribe", "publish"] }
],
"ttlSeconds": 900
}| Field | Rules |
|---|---|
subject | Required. 1–128 characters of [a-zA-Z0-9._-]. Your own identifier for the user. |
channels | Required. 1–32 entries. |
channels[].name | Required. A concrete channel name — no wildcards. |
channels[].ops | Required. 1–4 of subscribe, publish, presence, history. |
ttlSeconds | Optional integer, 60–3600. Defaults to 3600. |
{
"token": "eyJhbGciOiJFZERTQSIs...",
"gatewayUrl": "wss://<realtime-host>/ws",
"expiresAt": "2026-08-02T12:15:00.000Z"
}expiresAt is an ISO-8601 timestamp. Hand both token and gatewayUrl to @lessly/realtime-client; the WebSocket address is not a constant you hard-code, it is whatever this response returns for the environment you called.
What you declare is a ceiling, not a guarantee. Each channel is narrowed by the policy of its namespace: operations the namespace does not allow are dropped, and a channel in a namespace you have not registered is dropped whole. If nothing survives, the response is 422 — a token that connects and then fails every subscribe is never issued.
POST /messages
Publishes a message to a channel. Every client currently subscribed to that channel receives it.
{
"channel": "chat:room-1",
"data": { "text": "hello", "from": "user-8412" }
}channel must match namespace:rest and be 3–128 characters of [a-zA-Z0-9._:-]. data is any JSON value and is required.
{
"channel": "chat:room-1",
"published": true,
"offset": "1754136000000-0",
"epoch": "17"
}offset and epoch identify the stored message and are present only when the namespace retains history. Together they form the cursor a client passes to GET /messages/history to resume.
The namespace must be registered, otherwise the response is 404. Serialized data over 256 KiB is stored and delivered as a reference rather than inline — see limits and errors.
GET /messages/history
Reads back messages already published to a channel. Ask for either a cursor or a window.
| Parameter | Rules |
|---|---|
channel | Required. |
offset and epoch | A cursor. Both or neither. Returns entries strictly after offset. |
last_n | A window. Integer 1–1000. Returns the last N entries. |
last_ms | A window. Positive integer. Returns entries newer than now minus this many milliseconds. |
Passing only one half of the cursor, passing a cursor together with a window, or passing neither is a 400.
GET /messages/history?channel=chat:room-1&last_n=50{
"channel": "chat:room-1",
"epoch": "17",
"recovered": true,
"entries": [
{
"id": "01J8Z...",
"data": { "text": "hello" },
"ts": 1754136000000,
"offset": "1754136000000-0"
}
]
}epoch is the channel’s current stream epoch, or null when the channel has no history yet. recovered is false when the cursor’s epoch no longer matches or the entries it pointed at have aged out — the client missed messages and must resync rather than assume continuity.
An entry carries either data inline or a ref object (bucket_key, size, content_type) when the payload was stored rather than inlined.
GET /presence
Returns the current roster of a channel.
GET /presence?channel=chat:room-1{
"channel": "chat:room-1",
"members": [
{
"identity": "user-8412",
"connections": 2,
"info": { "name": "Ada" },
"ts": 1754136000000
}
]
}The roster is deduplicated by identity; connections counts that identity’s live sockets, so the same person in two tabs is one member. ts is the last update in milliseconds.
Presence must be enabled on the namespace, otherwise the response is 403. Entering, updating and leaving presence are not on this perimeter — clients do that over their own connection.
GET /presence/stats
Returns only the size of the roster, for when you need a count and not a list.
GET /presence/stats?channel=chat:room-1{ "channel": "chat:room-1", "members": 12 }members is the number of distinct identities present. The same 403 applies when presence is off for the namespace.
Next steps
- Authenticate your backend and your users: where the key comes from and which environment it belongs to.
- Call Realtime from your backend: the same five routes as typed methods, with retries and errors.
- Look up a limit or an error: every quota, status code and close code in one place.