Realtime
Realtime limits and errors
Every limit you can hit and every error you can be handed, in one place.
Every client-visible limit, HTTP status code, WebSocket close code and error frame. The five methods your API key reaches are listed on the server SDK page; the platform’s management routes — namespaces, grants, keys, webhooks and the archive — have their own Realtime API reference.
The public routes
Your backend reaches Realtime at one base path:
https://public.lessly.com/{productId}/realtime{productId} is the id of your product on the platform, and every route is relative to that base — so a publish is POST https://public.lessly.com/{productId}/realtime/messages. The five methods an API key reaches are listed on the server SDK page.
Authentication
Send your product API key in the X-Api-Key header. A key is rtk_ followed by 43 base64url characters of randomness — nothing else, and nothing in it encodes an environment or a product.
X-Api-Key: rtk_…The key is resolved to a product, and that product must be the one in the path.
| Case | Answer |
|---|---|
| Key missing, malformed, unknown or revoked | 401 with the message invalid_api_key, plus a hint and the gatewayUrl of the environment you actually reached |
| Valid key, but for a different product than the path | 404, not 403 — so the surface cannot be used to probe for other products |
Keys are per environment. A key minted on one environment does not exist on another, and reads there as simply invalid; the gatewayUrl in the 401 tells you which environment you hit.
This key is a backend credential. Never ship it to a browser — browsers get a capability token from
POST /tokens/issueinstead. See authentication.
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.
Names and identifiers
| Thing | Limit |
|---|---|
| Channel name | namespace:rest, 3–128 characters of [a-zA-Z0-9._:-]. A name with no namespace segment is invalid. |
| Namespace name | 1–64 characters of [a-zA-Z0-9._-]. No colon. |
| Token subject | 1–128 characters of [a-zA-Z0-9._-]. |
| Grant pattern | A concrete namespace segment, then up to 7 more segments; a segment may be *, which matches exactly one segment. |
Tokens
| Thing | Limit |
|---|---|
| Channels per token | 1–32 |
| Operations per channel | 1–4, from subscribe, publish, presence, history |
| Token lifetime | 60–3600 seconds; 3600 when you omit it |
Request and message size
| Thing | Limit |
|---|---|
Request body, all routes except POST /messages | 64 KiB (65 536 bytes) |
Request body, POST /messages | 256 KiB (262 144 bytes) |
| Client event published from a browser | 32 KiB (32 768 bytes) of serialized data |
POST /messages has the larger limit because payloads are inlined up to 256 KiB. Within a publish, the serialized data is compared against that same figure. At or below it, the payload is delivered inline. Above it, the payload is stored and subscribers receive a reference instead: a ref object with bucket_key, size and, when known, content_type. History entries follow the same rule — an entry carries either data or ref, never both.
Store-and-link needs payload storage provisioned for the product. Without it, an oversized publish fails with
503; publishes at or under the inline cap are unaffected.
Publish quota
Publishes are counted per product over fixed 10-second windows.
| Value | Default |
|---|---|
| Sustained rate | 50 publishes per second |
| Window | 10 seconds |
| Burst allowance | 100 publishes per window |
| Hard limit | 1 100 publishes per window (burst + twice the sustained allowance) |
These are the defaults a product starts with; they can be tuned per deployment, so measure rather than assume if the numbers matter to your design.
Only crossing the hard limit is rejected. There is no cliff at the sustained rate: you may run over it for a while, which is what the burst and the doubled allowance are for. A rejected publish returns 429 with a Retry-After header giving the seconds until the current window rolls.
The quota is accounted separately from the request rate limit of 100 requests per minute per IP address, and both apply.
Client events
Publishing from a browser is off unless the namespace enables client events. When it is on, each connection gets its own token bucket: 5 publishes per second, with a burst of 10. Over that, the publish is answered with an error frame of code rate_limited — the connection stays open.
Client events are ephemeral. They fan out and are never stored, so they carry no offset or epoch and cannot be replayed. The publishing connection is excluded from delivery, so the acknowledgement is its only confirmation.
Presence
| Thing | Limit |
|---|---|
| Members per channel | 100 |
Member info | 10 KiB (10 240 bytes) serialized |
| Entry lifetime | 1–3600 seconds; 60 when not given |
The roster is deduplicated by identity, so one person on several tabs is one member with a connections count. Entering a full roster is refused with 429.
History
| Thing | Limit |
|---|---|
last_n | 1–1000 entries |
last_ms | Any positive number of milliseconds |
| Retention window | Up to 30 days, set per namespace |
| Archive retention | Up to 3650 days, set per namespace |
History exists only where the namespace retains it. A read on a namespace with history off returns an empty result rather than an error.
A cursor is offset plus epoch together. Ask for a cursor or a window, never both and never neither. When the answer comes back with recovered: false, the cursor’s epoch no longer matches or the entries it pointed at have aged out: the client missed messages and must resync, not continue. See history.
HTTP status codes
| Code | What it means | What to do |
|---|---|---|
400 | The request failed validation — a malformed channel name, a half cursor, a cursor and a window together, a missing required field. The body names the failing fields. | Fix the request. Retrying it unchanged will fail the same way. |
401 | invalid_api_key: the X-Api-Key header is absent, malformed, unknown or revoked. The body carries a hint and the gatewayUrl of the environment you reached. | Check you are sending a key minted for that environment. Unknown and revoked are deliberately not told apart. |
403 | Presence is not enabled for the channel’s namespace. | Enable presence on the namespace, or stop reading presence there. |
404 | The namespace is not registered for your product — or your key is valid but belongs to a different product than the one in the path. | Register the namespace. If the namespace is right, check the product id in the URL against the key you used. |
422 | Every capability you declared on POST /tokens/issue was stripped by namespace policy, leaving nothing to mint. | Check the namespaces are registered and their policy allows the operations you asked for. |
429 | A publish beyond the hard quota, or presence on a full roster. Publishes carry Retry-After. | Wait out Retry-After and retry. If it recurs at a steady rate, you are over the sustained quota, not merely bursting. |
503 | Fan-out or history is unavailable, or a payload is over the inline cap and no payload storage is provisioned. | Retry with backoff. The oversized-payload case will not clear on its own — provision storage or publish smaller payloads. |
WebSocket close codes
The browser client handles these for you; they matter when you are reading logs or writing your own client.
| Code | What it means | What the client does |
|---|---|---|
4001 | The connection carries no valid token. | Reconnect immediately with a freshly minted token. |
4100 | The realtime service is draining, on shutdown or redeploy. | Reconnect immediately; another node takes the connection. |
4200 | Slow consumer: the connection’s per-channel buffer overflowed and messages were dropped. | Reconnect with backoff and come back through cursor recovery — the gap is real and must be replayed, not ignored. |
@lessly/realtime-client reconnects at once on 4001 and 4100, since neither means the connection was at fault, and backs off on everything else.
WebSocket error frames
An error frame does not close the connection. It answers one action.
| Code | What it means |
|---|---|
invalid | The frame was malformed, or the action was not allowed in this state — an unparseable frame, a missing channel, a client publish over 32 KiB, presence info too large, or a presence update from a connection that is not in the roster. |
forbidden | The token does not carry the operation for that channel. |
unavailable | The realtime service is up and can verify your token, but cannot serve subscribe, publish or presence work right now. Retry with backoff. |
rate_limited | The connection exceeded its client-event budget. |
forbiddenwill not fix itself by retrying: the token is the problem, so mint a new one with the operation you need — if namespace policy allows it.
Next steps
- Authenticate your backend and your users: mint a token that carries the operations you need.
- Connect a browser tab: how the client reacts to each close code.
- Replay what a client missed: what to do when a recovery is refused.
- Look up a management route: request and response bodies for namespaces, grants, keys and webhooks.