# The public HTTP routes

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

```text
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:

```text
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](/ship/realtime/authentication).

> 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

| 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](/ship/realtime/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.

```json
{
  "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. |

```json
{
  "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.

```json
{
  "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.

```json
{
  "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](/ship/realtime/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`.

```text
GET /messages/history?channel=chat:room-1&last_n=50
```

```json
{
  "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.

```text
GET /presence?channel=chat:room-1
```

```json
{
  "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.

```text
GET /presence/stats?channel=chat:room-1
```

```json
{ "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](/ship/realtime/authentication): where the key comes from and which environment it belongs to.
- [Call Realtime from your backend](/ship/realtime/server-sdk): the same five routes as typed methods, with retries and errors.
- [Look up a limit or an error](/ship/realtime/limits-and-errors): every quota, status code and close code in one place.
