Skip to content

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.

StatuscodeWhen
401public_key_requiredNo key was presented
401public_key_invalidThe key matches no active key of the product in the path — unknown, mistyped, revoked (once the revocation has propagated), or belonging to another product
403public_key_scopeThe key is active but its scope does not cover this route — or it was revoked moments ago and the revocation has not propagated yet
503public_config_unavailableNo 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/issue instead.

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

LimitValue
Request body, all routes except POST /messages64 KiB (65 536 bytes)
Request body, POST /messages256 KiB (262 144 bytes)
Requests100 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
}
FieldRules
subjectRequired. 1–128 characters of [a-zA-Z0-9._-]. Your own identifier for the user.
channelsRequired. 1–32 entries.
channels[].nameRequired. A concrete channel name — no wildcards.
channels[].opsRequired. 1–4 of subscribe, publish, presence, history.
ttlSecondsOptional 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.

ParameterRules
channelRequired.
offset and epochA cursor. Both or neither. Returns entries strictly after offset.
last_nA window. Integer 1–1000. Returns the last N entries.
last_msA 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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect