> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rivi.software/llms.txt
> Use this file to discover all available pages before exploring further.

# TypeScript SDK

> Install and use @rivi-software/radiotools-sdk for reads, pagination, the public realtime feed, caching, errors, and playback ingest.

`@rivi-software/radiotools-sdk` is the complete RadioTools client. It runs anywhere `fetch` and Web Crypto are available: Node.js, Bun, Deno, and edge runtimes.

## Install

```bash theme={null}
npm install @rivi-software/radiotools-sdk
```

This page describes version 0.3.0. The realtime feed, slug lookup, content sorting and filters, the preview handoff, and media crop variants need 0.3.0 or later.

## Create a client

Set the [environment variables](/radiotools/api-reference/sdks#credentials), then:

```ts theme={null}
import { createStationClientFromEnv } from "@rivi-software/radiotools-sdk";

const client = createStationClientFromEnv();
const nowPlaying = await client.getNowPlaying();
console.log(nowPlaying.track?.title);
```

Or pass options explicitly:

```ts theme={null}
import { createStationClient } from "@rivi-software/radiotools-sdk";

const client = createStationClient({
  baseUrl: "https://radiotools.rivi.software",
  stationSlug: "radio-rivier",
  apiToken: process.env.RADIOTOOLS_API_TOKEN,
});
```

`createStationClient` requires `stationSlug` and at least one of `apiToken` or `ingestToken`, and throws when either is missing. Reads and `subscribe` need `apiToken`; `reportPlayback` needs `ingestToken`. `createStationClientFromEnv(env?, overrides?)` reads `process.env` unless you pass an environment object, which is useful on runtimes without `process`. `stationClientOptionsFromEnv(env?)` returns the same options without creating a client.

### Options

| Option | Default | Purpose |
| - | - | - |
| `baseUrl` | — | RadioTools origin |
| `stationSlug` | — | Station to read from |
| `apiToken` | — | Station key (`listener:read`) |
| `ingestToken` | — | Integration key (`ingest:write`) |
| `transport` | `"http"` | `"http"`, `"graphql"`, or `"auto"` (GraphQL, falling back to HTTP when the GraphQL route is missing) |
| `retries` | `2` | Extra attempts after HTTP `429` or `503` |
| `etagCache` | none | Cache for conditional reads; see [Caching](#caching-and-retries) |
| `fetch` | global `fetch` | Custom `fetch`, for tests or proxies |
| `createSocket` | global `WebSocket` | `(url) => WebSocket` factory for runtimes without a global `WebSocket`; see [Realtime](#realtime-updates) |

## Read station data

Methods resolve resource links from the station document, so the first call also loads the station. The client remembers it for later calls.

| Method | Returns |
| - | - |
| `getStation()` | Station identity, profile, resource links, realtime feed location, and polling policy |
| `getSchemas()` | Published content types and field definitions |
| `getContent({ type, cursor?, limit?, sort?, direction?, filter? })` | One page of published records, newest first by default |
| `getContentRecord(id)` | One published record |
| `getContentBySlug(type, slug)` | The published record a slug names, including a slug it used before |
| `iterateContent({ type, limit?, sort?, direction?, filter? })` | Async iterator over every record |
| `getSchedule({ fromDate, throughDate })` | Broadcasts in a date window |
| `getPrograms({ fromDate, throughDate })` | Programs in a date window, with presenters and regular slots |
| `getCurrentBroadcast()` | The current public broadcast |
| `getNowPlaying()` | Latest reported track with freshness |
| `getTrackHistory({ cursor?, limit? })` | One page of track history |
| `iterateTrackHistory({ limit? })` | Async iterator over track history |
| `getWeather()` / `getTraffic()` | Snapshot, or `null` when the station does not publish it |
| `recommendedPollIntervalMs()` | Milliseconds from `station.delivery.pollIntervalSeconds` |

Schedule windows use `YYYY-MM-DD` dates and cover 1 to 31 inclusive days. Other values throw before any request is sent. The default page size is 25, and a content page holds up to 50 records.

```ts theme={null}
const schedule = await client.getSchedule({
  fromDate: "2026-10-06",
  throughDate: "2026-10-12",
});

for await (const record of client.iterateContent({ type: "news" })) {
  console.log(record.id);
}
```

### Sort and filter content

`sort` is `"publishedAt"` (first publication, the default) or `"updatedAt"` (when the live version was published). `direction` defaults to `"desc"`, newest first. Reuse a `nextCursor` only with the same `sort` and `direction`.

`filter` takes up to 10 filters on text and choice fields. A record must match all of them. Write each filter as an object or as the REST string form:

| Object | String | Matches |
| - | - | - |
| `{ field: "category", value: "sports" }` or `{ field: "category", op: "eq", value: "sports" }` | `"category:sports"` | The exact value |
| `{ field: "title", op: "contains", value: "storm" }` | `"title.contains:storm"` | A case-insensitive substring |
| `{ field: "regions", op: "anyOf", values: ["noord", "zuid"] }` | `"regions.anyOf:noord,zuid"` | Any of the values, including any key of a multi-select choice field |

```ts theme={null}
const sports = await client.getContent({
  type: "news",
  limit: 10,
  sort: "updatedAt",
  filter: [{ field: "category", value: "sports" }],
});
```

Sorting and filtering work with both the HTTP and GraphQL transports. See [Requests and response handling](/radiotools/api-reference/response-handling) for cursor pagination.

### Look up a record by slug

`getContentBySlug(type, slug)` returns the published record a slug names. A slug the record used before also resolves, so compare `values.slug` with the slug you asked for and redirect when they differ:

```ts theme={null}
const article = await client.getContentBySlug("news", "storm-over-de-rivier");
if (article.values.slug !== "storm-over-de-rivier") {
  // Redirect permanently to /news/${article.values.slug}
}
```

### Poll politely

```ts theme={null}
const everyMs = await client.recommendedPollIntervalMs();
setInterval(async () => {
  const nowPlaying = await client.getNowPlaying();
  // …
}, everyMs);
```

Prefer [realtime updates](#realtime-updates) when your server or page can hold an open connection.

## Realtime updates

The station's public realtime feed pushes now-playing, on-air, schedule, and content changes. `station.resources.realtime` holds the feed's WebSocket `url` and `channel`, and it is `null` when the feed is switched off. The feed needs no credential. See [Public realtime feed](/radiotools/api-reference/public-delivery#public-realtime-feed).

Realtime support needs version 0.3.0 or later.

### Subscribe with the client

`client.subscribe(handlers)` reads the station document, then subscribes to the feed. It needs `apiToken` for that first read.

```ts theme={null}
import { createStationClientFromEnv } from "@rivi-software/radiotools-sdk";

const client = createStationClientFromEnv();

const subscription = client.subscribe({
  onNowPlaying: (nowPlaying) => console.log(nowPlaying.track?.title),
  onAir: (onAir) => console.log(onAir?.status),
  onSchedule: (schedule) => console.log(schedule.broadcasts.length),
  onContent: (recordId, record) => console.log(recordId, record?.revision),
  onError: (error) => console.error(error),
});

// Later:
subscription.close();
```

| Handler | Fires when |
| - | - |
| `onNowPlaying` | A new track is reported |
| `onAir` | The on-air broadcast changes; the value is `null` when nothing is on air |
| `onSchedule` | The published schedule changes; the feed covers today through seven days later |
| `onContent` | A published record changes; the record is `null` when it is unpublished or removed |
| `onError` | The station has no realtime feed, the station read fails, the socket cannot open, or the connection reports an error |

Each connection starts with the station's current state, so your handlers receive a snapshot before live changes arrive. When the connection closes, the SDK reconnects with backoff (1, 2, 5, 10, then 30 seconds) until you call `close()`. After a reconnect, the handlers receive the current state again.

The SDK ignores rows for other stations, events on other channels, and messages that do not match protocol `radiotools.public.v1`. The feed URL must use `wss:`; plain `ws:` is allowed only for `localhost`, `127.0.0.1`, and `[::1]`.

### Subscribe from a browser

Because the feed needs no credential, a browser can subscribe directly with `subscribePublicFeed`. Read the station document on your server, then pass `resources.realtime` and the station `id` to the page. The station key stays on your server.

```ts theme={null}
import { subscribePublicFeed } from "@rivi-software/radiotools-sdk";

// `realtime` and `stationId` come from your server: station.resources.realtime and station.id
const subscription = subscribePublicFeed({
  realtime,
  stationId,
  handlers: {
    onNowPlaying: (nowPlaying) => render(nowPlaying),
    onError: (error) => console.error(error),
  },
});
```

| Option | Default | Purpose |
| - | - | - |
| `realtime` | — | The `station.resources.realtime` object, `{ url, channel }` |
| `stationId` | — | The station `id` from the station document |
| `handlers` | — | The same handlers as `client.subscribe` |
| `createSocket` | global `WebSocket` | `(url) => WebSocket` factory |

Browsers, Bun, Deno, and Node.js 22 or newer have a global `WebSocket`. On older Node.js versions, pass a factory, for example with the `ws` package:

```ts theme={null}
import WebSocket from "ws";

const client = createStationClientFromEnv(undefined, {
  createSocket: (url) => new WebSocket(url) as unknown as globalThis.WebSocket,
});
```

<Note>
  **Upgrading from 0.2.** Version 0.3.0 replaces `station.resources.socket` with `station.resources.realtime`. `subscribePublicSocket` is replaced by `subscribePublicFeed`. `subscribe` no longer takes a schedule window argument, and the `createSocket` factory receives only the URL. `PublicSocketHandlers` and `PublicSocketSubscription` remain as deprecated aliases of `PublicFeedHandlers` and `PublicFeedSubscription`. Content cursors (`nextCursor`) are now opaque strings instead of UUIDs.
</Note>

## Caching and retries

Pass an ETag cache to turn repeat reads into conditional requests. A `304` response reuses the cached data.

```ts theme={null}
import { createEtagCache, createStationClientFromEnv } from "@rivi-software/radiotools-sdk";

const client = createStationClientFromEnv(undefined, { etagCache: createEtagCache() });
```

* The cache is in memory. Create it once and reuse it for the life of the process.
* Cache keys include a hash of the station key, so rotating the key does not serve stale data across credentials.
* `429` and `503` responses are retried up to `retries` times. The SDK honors a numeric `Retry-After` header (capped at 5 seconds) and otherwise backs off exponentially from 250 ms, also capped at 5 seconds.

## Handle errors

API failures throw `RadioToolsApiError`:

```ts theme={null}
import { RadioToolsApiError } from "@rivi-software/radiotools-sdk";

try {
  await client.getNowPlaying();
} catch (error) {
  if (error instanceof RadioToolsApiError) {
    console.error(error.status, error.code, error.requestId, error.issues);
  } else {
    throw error;
  }
}
```

| Property | Meaning |
| - | - |
| `status` | HTTP status code |
| `code` | Stable error code such as `UNAUTHENTICATED`, `FORBIDDEN`, `VALIDATION_ERROR`, `NOT_FOUND`, `MODULE_DISABLED`, `RATE_LIMITED`, or `PROVIDER_UNAVAILABLE`; `null` for ingest errors, which only carry a message |
| `requestId` | Include this when contacting support; `null` for ingest errors |
| `issues` | Field-level details when present: `path`, `message`, and optionally a stable `code`, a `suggestion` you could send instead, and `retryAfter` in seconds |

See [Errors and versioning](/radiotools/api-reference/errors-and-versioning) for what each code means and how to recover.

The SDK also throws plain `Error` for problems it detects locally: a missing token, an invalid schedule window, an unavailable resource such as a station without a published schedule, an oversized playback observation, or an unparseable error body. Responses are validated against the contract schemas, so a response that does not match throws a Zod error instead of returning unexpected data.

## Report playback

Encoders and automation send now-playing observations with `reportPlayback`. This requires `ingestToken`.

```ts theme={null}
const result = await client.reportPlayback({
  artist: "The Riverlights",
  title: "Morning Sun",
  album: "Open Water",
  durationSeconds: 210,
  isPlaying: true,
});

console.log(result.accepted, result.outcome);
```

* Send `artist` and `title` together, or send `streamTitle` alone.
* If you omit `eventId`, the SDK generates one for the call, so automatic retries of that call cannot record the same observation twice. Set your own stable `eventId` when your source can resend the same event.
* Requests over 8 KiB are rejected before they are sent.
* `isPlaying` defaults to `true`.
* By default the station comes from the client's `stationSlug`. To target a station by ID, pass `{ stationId }` as the second argument.

Without a client, call the standalone `reportPlayback({ baseUrl, ingestToken, stationSlug, observation })`. Pass `stationSlug` or `stationId`, not both.

`outcome` is one of `recorded`, `matched`, `uncertain`, `ignored`, `not-playing`, `no-live-broadcast`, `on-air-not-following-rundown`, or `rejected`. Field details are in [Now-playing ingest](/radiotools/api-reference/now-playing-ingest).

## Media URLs

Each published media entry carries `url` (the original), `alt`, `width`, `height`, `duration`, and `variants`. `variants.thumb` and `variants.display` are smaller resizes, and `variants.card` (16:9) and `variants.square` are crops around the image's focal point. Each variant is `null` when it does not exist. Use them for `srcset` and list pages, and use `width` and `height` to reserve the aspect ratio.

`publicMediaUrl(baseUrl, mediaResourcePrefix, versionId, variant?)` builds an anonymous media URL from `station.resources.media` and a `versionId`. `variant` is `"thumb"`, `"display"`, `"card"`, or `"square"`; omit it for the original. The SDK builds URLs only; your page or CDN fetches the bytes.

```ts theme={null}
import { publicMediaUrl } from "@rivi-software/radiotools-sdk";

const station = await client.getStation();
const article = await client.getContentBySlug("news", "storm-over-de-rivier");
const [image] = article.media;
const cardUrl = image
  ? publicMediaUrl("https://radiotools.rivi.software", station.resources.media, image.versionId, "card")
  : null;
```

## Types and schemas

The package exports a type for every response, such as `SdkPublicNowPlaying` and `SdkPublishedRecord`, plus the Zod schemas as `sdkSchemas`. Option and handler types include `CreateStationClientOptions`, `ContentListOptions`, `ContentFilter`, `PublicFeedHandlers`, `PublicFeedSubscription`, `SubscribePublicFeedOptions`, `SdkPublicRealtimeDescriptor`, `PublicMediaVariant`, `PreviewHandoff`, and `PreviewEditorialOptions`. Use the schemas to validate data you store or forward.

```ts theme={null}
import { sdkSchemas, type SdkPublicNowPlaying } from "@rivi-software/radiotools-sdk";

const parsed: SdkPublicNowPlaying = sdkSchemas.PublicNowPlaying.parse(untrusted);
```

`LISTENER_CONTRACT_VERSION` (`1`) and `PUBLIC_SOCKET_PROTOCOL` (`"radiotools.public.v1"`) expose the contract constants.

## Preview unpublished content

`client.previewEditorial(grant, input)` renders unpublished editorial content with a 15-minute preview grant. Pass the grant explicitly, never from a URL. With `transport: "graphql"` the SDK sends the station key and the grant in `X-Preview-Grant`; over HTTP it sends the grant as the Bearer token. Studio session cookies are never sent.

When the station has set a draft preview address and an editor clicks **Open on website**, RadioTools posts the grant to that address as form fields: `grant`, `stationId`, `recordId`, `revision`, and `audience`. `readPreviewHandoff` reads them from `FormData`, `URLSearchParams`, or a plain object. The standalone `previewEditorial` then fetches the draft. The grant covers one revision and audience.

```ts theme={null}
import { previewEditorial, readPreviewHandoff } from "@rivi-software/radiotools-sdk";

export async function POST(request: Request) {
  const { grant, input } = readPreviewHandoff(await request.formData());
  const draft = await previewEditorial({
    baseUrl: "https://radiotools.rivi.software",
    grant,
    input,
  });
  return renderDraft(draft); // Your own layout; send Cache-Control: no-store
}
```

The standalone `previewEditorial` uses HTTP by default. For GraphQL, pass `transport: "graphql"` with `stationSlug` and `apiToken`.


## Related topics

- [Framework bindings](/radiotools/api-reference/sdks/frameworks.md)
- [Other languages](/radiotools/api-reference/sdks/other-languages.md)
- [GraphQL schema reference](/radiotools/api-reference/graphql-reference.md)
- [GraphQL](/radiotools/api-reference/graphql.md)
- [Public delivery](/radiotools/api-reference/public-delivery.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.