# Call Realtime from your backend

`@lessly/realtime` is the Node package your backend uses to talk to Realtime. It publishes messages, reads history, mints capability tokens for your users, and manages namespaces, grants, presence and webhooks. The current published version is **0.5.0**.

How much of that a given client reaches is decided by the credential you construct it with. An SDK Identity token reaches every resource in the package; a product public key reaches the five public routes and no management resource. The boundary is below.

It is a server-side package. It carries a credential, so it must never be bundled into a browser — for the browser see [the browser client](/ship/realtime/browser-client).

## Install and construct

```bash
npm install @lessly/realtime
```

The package targets Node 22 and above and uses the global `fetch`. `Realtime` takes one of two construction forms, one per credential. Passing both throws `cannot combine a bearer token with apiKey — pick one auth mode`.

### With a product public key

The SDK builds the base URL for you as `{edgeUrl}/{productId}/realtime` and sends the key as `Authorization: Bearer <apiKey>`. The option is still named `apiKey`; what changed in 0.5.0 is the header it travels in.

```ts

const realtime = new Realtime({
  apiKey: process.env.REALTIME_API_KEY!,   // lpk_… , or an older rtk_… key
  productId: process.env.PRODUCT_ID!,
})
```

| Option | Type | Meaning |
|---|---|---|
| `apiKey` | `string` | Product public key — `lpk_…`, or an `rtk_…` key issued before the migration. Required. |
| `productId` | `string` | Product id, the first path segment on the public routes. Required. |
| `edgeUrl` | `string` | Public edge origin. Defaults to `DEFAULT_EDGE_URL`. |
| `timeout` | `number` | Per-request timeout in ms. Defaults to `30000`. |
| `retry` | `RetryConfig` | Retry policy, see below. |
| `headers` | `Record<string, string>` | Extra headers merged into every request. |

`DEFAULT_EDGE_URL` is exported and is `https://public.lessly.com` — production.

> `.com` and `.dev` are separate environments with separate databases, so a key created on production fails with `401 public_key_invalid` against a `.dev` origin — and nothing in that response says the host was the problem. Set `edgeUrl` only to point at a non-production environment.

An empty `apiKey` or `productId` throws. The message never contains the credential.

### With an SDK Identity token

The other form takes an SDK Identity token as the first argument and an explicit `baseUrl`. The SDK sends the token as `Authorization: Bearer <token>`. This is the credential the management resources answer to.

```ts
const realtime = new Realtime(process.env.REALTIME_TOKEN!, {
  baseUrl: process.env.REALTIME_BASE_URL!,
})
```

An empty token or an empty `baseUrl` throws. `timeout`, `retry` and `headers` mean the same thing in both forms.

The constructed client exposes six resources as readonly properties, whichever credential it carries: `messages`, `tokens`, `namespaces`, `grants`, `presence`, `webhooks`.

## The access boundary

The resources are the same; what answers them is not. An SDK Identity token reaches every method on this page. A product public key reaches [the public HTTP routes](/ship/realtime/public-api), and those serve these five methods:

| Method | Route |
|---|---|
| `tokens.issue` | `POST /tokens/issue` |
| `messages.publish` | `POST /messages` |
| `messages.history` | `GET /messages/history` |
| `presence.get` | `GET /presence` |
| `presence.stats` | `GET /presence/stats` |

The rest of the client — `tokens.create`, the `namespaces`, `grants` and `webhooks` resources, and `presence.enter` / `update` / `leave` — is outside what a product public key reaches. The management resources answer to an SDK Identity token, and the same records are edited by hand in the workspace, under **Realtime → Namespaces**, **Realtime → Grants** and **Realtime → Webhooks**.

The key itself is on neither list. Public keys are created, scoped and revoked by a product Owner or Admin on the **Product → Settings → Public Access** page; no method of this SDK mints, lists or revokes one. Presence is entered and updated from the browser client, on the connection that is present.

## Publish and read history

```ts
publish(channel: string, data: unknown): Promise<PublishMessageResponse>
history(channel: string, query: HistoryQuery): Promise<HistoryGetResponse>
```

`publish` sends `data` to every subscriber of `channel`. The response is `{ channel, published: true }`, plus `offset` and `epoch` when the namespace history policy stored the message.

```ts
const result = await realtime.messages.publish('chat:room-1', {
  text: 'hello',
  from: 'ada',
})
// result.offset — pass it to a client so it can resume from here
```

`history` reads back what was sent. The query is either a cursor or a window:

```ts
type HistoryQuery =
  | { cursor: { offset: string; epoch: string } }
  | { lastN?: number; lastMs?: number }
```

```ts
const page = await realtime.messages.history('chat:room-1', { lastN: 50 })
if (!page.recovered) {
  // the cursor epoch no longer matches, or the entries aged out — resync
}
for (const entry of page.entries) {
  // entry.id, entry.ts, entry.offset, and entry.data or entry.ref
}
```

`recovered` is `false` when the cursor cannot be honoured; treat that as "start again from a snapshot", covered in [history](/ship/realtime/history). An entry carries either an inline `data` payload or a `ref` (`{ bucket_key, size, content_type }`) when the payload was too large to inline.

## Mint tokens

Two methods mint, and only one of them is the one you want:

| Method | Mints for | Reachable with a product public key |
|---|---|---|
| `tokens.issue` **(Recommended)** | One of your end users | Yes |
| `tokens.create` | The calling credential itself | No |

`issue` takes the subject, the concrete channels and the operations the user may perform on each:

```ts
interface IssueTokenInput {
  subject: string                  // 1..128 chars
  channels: { name: string; ops: ChannelOp[] }[]   // 1..32 concrete channels, no wildcards
  ttlSeconds?: number              // 60..3600, defaults to 3600 server-side
}

type ChannelOp = 'subscribe' | 'publish' | 'presence' | 'history'
```

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

The declared operations are narrowed by namespace policy when the token is minted, and a request whose capabilities are stripped entirely fails with `422`. Return `token` and `gatewayUrl` to the browser; `expiresAt` is the ISO-8601 expiry. See [authentication](/ship/realtime/authentication).

`create` mints a token for the calling credential itself, optionally scoped to a list of channels, and returns `{ token, gatewayUrl }`. It does not mint for one of your end users and is not reachable with a product public key — use `issue`.

## Retries

Every request goes through the retry policy.

```ts
interface RetryConfig {
  maxAttempts?: number   // default 3
  initialDelay?: number  // default 500 (ms)
  maxDelay?: number      // default 5000 (ms)
}
```

A request is retried when it fails with HTTP 429, any status of 500 or above, or a network error (status code `0`). Every other failure is thrown immediately.

The delay before the next attempt is `initialDelay * 2 ** attempt`, capped at `maxDelay`. When the response carried a `Retry-After` header, that value wins and the SDK waits exactly that many seconds instead. `maxAttempts` counts the first attempt, so the default of 3 means one call and at most two retries; the error from the last attempt is thrown.

## Errors

Failures throw `RealtimeError` or one of its subclasses. Every instance carries:

| Property | Type | Meaning |
|---|---|---|
| `statusCode` | `number` | HTTP status. `0` for a network failure. |
| `errorType` | `string` | Machine-readable error code parsed from the body. |
| `retryAfter` | `number \| undefined` | Seconds from the `Retry-After` header; set on rate limits. |

The subclass is chosen by status:

| Status | Class |
|---|---|
| 400 | `ValidationError` |
| 401 | `AuthenticationError` |
| 403 | `ForbiddenError` |
| 404 | `NotFoundError` |
| 409 | `ConflictError` |
| 422 | `UnprocessableEntityError` |
| 429 | `RateLimitError` |
| 503 | `ServiceUnavailableError` |
| any other | `InternalError` |

`NetworkError` covers a failed or timed-out connection. It has status code `0` and error type `Network Error`; a timeout reports `Request timed out after {timeout}ms`.

```ts

try {
  await realtime.messages.publish('chat:room-1', { text: 'hello' })
} catch (error) {
  if (error instanceof RateLimitError) {
    // error.retryAfter is the server's advice, in seconds
  } else if (error instanceof RealtimeError) {
    console.error(error.statusCode, error.errorType, error.message)
  }
}
```

`parseErrorBody(status, body)` and `createErrorFromResponse(status, body, retryAfter?)` are exported too, for code that handles raw HTTP responses itself.

## Namespaces, grants and webhooks

These methods answer to an SDK Identity token; a product public key does not reach them. The same records are created and edited by hand in the workspace, under **Realtime → Namespaces**, **Realtime → Grants** and **Realtime → Webhooks**. The shapes are documented here because the policy they carry decides what your channels allow, and because your endpoint has to handle what a webhook delivers.

```ts
create(input: { name: string } & NamespacePolicyInput): Promise<NamespaceView>
list(): Promise<NamespaceView[]>
get(name: string): Promise<NamespaceView>
update(name: string, patch: NamespacePolicyInput): Promise<NamespaceView>
delete(name: string): Promise<{ deleted: true }>
```

The policy fields, all optional on both create and update:

| Field | Type | Meaning |
|---|---|---|
| `visibility` | `'public' \| 'authorized'` | Whether any subscriber is allowed, or only authorized ones. |
| `presence` | `boolean` | Whether a roster is kept for channels in the namespace. |
| `clientEvents` | `boolean` | Whether connected clients may publish directly. |
| `history` | `'none' \| 'last-message' \| 'window'` | What is retained. |
| `historyWindowSeconds` | `number` | Retention window when `history` is `window`. |
| `encryptionRequired` | `boolean` | Whether payloads must be encrypted. |
| `identifiedOnly` | `boolean` | Whether anonymous subjects are refused. |
| `subscribeProxyUrl` | `string \| null` | HTTPS callback consulted per subscribe on `authorized` namespaces. `null` clears it. |

`NamespaceView` returns the resolved policy plus `id`, `name`, `createdAt` and `updatedAt`. The rest of the namespace resource is `list()`, `get(name)` and `delete(name)`, which answers `{ deleted: true }`.

> `subscribeProxySecret` — the full signing secret for the subscribe proxy — appears only in the response that set or changed `subscribeProxyUrl`. Afterwards only `subscribeProxySecretPrefix` is visible, and it is `null` when no proxy URL is set.

A grant is a durable permission: a subject, a channel pattern and a set of operations. The resource carries `create(input)`, `list(filter?)` — optionally filtered by subject — and `revoke(id)`, which answers `{ revoked: true }`. `CreateGrantInput` is `{ subject, pattern, ops }`, where `subject` is an identity id or `*` for every identity in the product, and `pattern` is a channel pattern such as `chat:*` in which `*` matches exactly one segment. `GrantView` is `{ id, subject, pattern, ops, createdAt }`. The pattern rules are on [Realtime](/ship/realtime).

The `webhooks` resource carries `create`, `list`, `get`, `update`, `delete`, `rotateSecret` and `deliveries`.

- `create` takes `{ url, events, description? }` and answers `{ webhook, secret }`, where `secret` is the full signing secret and is returned exactly once, at creation.
- `update` accepts `url`, `events`, `description` (`null` clears it) and `active`. An inactive webhook receives no deliveries.
- `rotateSecret` returns the new `secret` — again only once — its `prefix`, and `previousExpiresAt`, the moment the rotated-out secret stops verifying. Until then both secrets verify, so a receiver can be updated without dropping deliveries.
- `WebhookView` never contains a secret. It lists `secrets` as metadata only: `{ prefix, expiresAt, createdAt }`, where `expiresAt` is `null` for the current signer and the `prefix` matches the `X-Realtime-Key` header on a delivery.
- `deliveries` returns the recent attempts, newest first, each as `{ id, eventType, eventId, status, attempts, responseStatus, lastError, lastAttemptAt, createdAt }` with `status` one of `pending`, `succeeded`, `failed`.

What a delivery looks like and how to verify it is on [webhooks](/ship/realtime/webhooks).

The `presence` resource reads a roster with `get(channel)` and `stats(channel)`; its `enter`, `update` and `leave` methods are not reachable with a product public key, because a member enters presence from the browser client, on the connection that is present. See [presence](/ship/realtime/presence).

## Next steps

- [Connect a browser tab](/ship/realtime/browser-client): the other half of the loop.
- [Authenticate your backend and your users](/ship/realtime/authentication): what the token you mint can and cannot carry.
- [Replay what a client missed](/ship/realtime/history): cursors, windows and what a refused recovery means.
- [Call the public HTTP routes](/ship/realtime/public-api): the same five routes without the SDK.
- [Look up a limit or an error](/ship/realtime/limits-and-errors): quotas, status codes and payload sizes.
