> ## 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.

# GraphQL schema reference

> Every query, type, enum, JSON shape, error code, and limit in the listener GraphQL API.

This page is the complete reference for the listener GraphQL schema. For setup, authentication, and the in-browser playground, start with [GraphQL](/radiotools/api-reference/graphql).

All operations are scoped to one station by the URL: `/api/v1/stations/{stationSlug}/graphql`, with `GET` or `POST`. Every query needs a Bearer credential with `listener:read`. All data is **published-only**, matching the HTTP API.

## Conventions

* **`!` means non-null.** A field without `!` can be `null`; a required nullable field is still always present in the response when you select it.
* **IDs** are strings. Content, broadcast, program, and presenter IDs are UUIDs; the station `id` is a station identifier and is separate from the `slug`. Pagination cursors are opaque strings, even where the type is `ID`.
* **Timestamps** such as `startsAt` and `observedAt` are ISO 8601 strings in UTC. **Dates** such as `fromDate` are local `YYYY-MM-DD` strings; interpret them in the station's `timezone`.
* **Selection is required.** GraphQL returns only the fields you list. There is no `*`; ask for each field you need.
* **`contractVersion`** is exposed on `Station`, `PublishedRecord`, `Schedule`, `Programs`, `CurrentBroadcast`, `NowPlaying`, and `TrackHistory`. Check that it equals `1` in your client. `ContentSchema`, `ContentCollection`, `Weather`, `Traffic`, and `RevisionPreview` do not expose it in GraphQL.
* **Aliases** let you call one field twice with different arguments in a single request.

## Queries

### `station`

```graphql theme={null}
station: Station!
```

The station entry document. `profile` holds the public station details once the station has saved them. `resources` lists the REST paths for each resource, and a `null` entry means that resource is disabled or not entitled. Check `resources.<name>` before querying a resource that can be disabled. `resources.realtime` tells a client where to follow the station's public realtime feed.

REST equivalent: `GET /api/v1/stations/{stationSlug}`.

### `schemas`

```graphql theme={null}
schemas(type: String, version: Int): [ContentSchema!]!
```

| Argument | Type | Notes |
| - | - | - |
| `type` | `String` | A content type key: lowercase, starting with a letter, up to 48 characters of `a-z`, `0-9`, and `_` |
| `version` | `Int` | A positive schema version. Requires `type`. |

Returns the published content type definitions, or one type when `type` is set. Use them to interpret the `values` of a record. Passing `version` without `type` returns `VALIDATION_ERROR`.

REST equivalent: `GET …/schemas`.

### `content`

```graphql theme={null}
content(
  type: String!
  cursor: ID
  limit: Int = 25
  sort: ContentSort = publishedAt
  direction: SortDirection = desc
  filter: [ContentFilter!]
): ContentCollection!
```

| Argument | Type | Notes |
| - | - | - |
| `type` | `String!` | Content type key, same format as above |
| `cursor` | `ID` | The `nextCursor` from the previous page. Opaque, up to 400 characters. |
| `limit` | `Int` | 1–50, default 25 |
| `sort` | [`ContentSort`](#contentsort) | `publishedAt` (default) orders by first publication; `updatedAt` by when the live version was published |
| `direction` | [`SortDirection`](#sortdirection) | `desc` (default) is newest first |
| `filter` | [`[ContentFilter!]`](#contentfilter) | Up to 10 filters on text and choice delivery fields. A record must match all of them. |

Returns published records of one type, newest first by default. `nextCursor` is `null` on the last page. A cursor only works with the same `sort` and `direction` that produced it; otherwise the call returns `VALIDATION_ERROR`.

Returns `NOT_FOUND` when `station.resources.content` is `null`. Filtering on a field that is not a delivery field of the type, or is not a text or choice field, returns `VALIDATION_ERROR`. An unknown type returns an empty page, or `NOT_FOUND` when you pass a filter.

REST equivalent: `GET …/content?type=<key>`, with the same `sort`, `direction`, and `filter` parameters.

### `contentRecord`

```graphql theme={null}
contentRecord(id: ID!): PublishedRecord!
```

One published record by UUID. Returns `NOT_FOUND` when the record is unpublished, belongs to another station, or the content resource is disabled. A non-UUID `id` returns `VALIDATION_ERROR`.

REST equivalent: `GET …/content/{id}`.

### `contentBySlug`

```graphql theme={null}
contentBySlug(type: String!, slug: String!): PublishedRecord!
```

| Argument | Type | Notes |
| - | - | - |
| `type` | `String!` | Content type key |
| `slug` | `String!` | 1–200 characters |

The published record a slug names. A slug the record used before also resolves, and returns the record as it is now. Compare `values.slug` with the slug you requested, and redirect to the current URL when they differ. Returns `NOT_FOUND` when no published record of that type has used the slug, or the content resource is disabled.

REST equivalent: [`GET …/content/{type}/by-slug/{slug}`](/radiotools/api-reference/public-listener/get-content-record-by-slug).

### `schedule`

```graphql theme={null}
schedule(fromDate: String!, throughDate: String!): Schedule!
```

| Argument | Type | Notes |
| - | - | - |
| `fromDate` | `String!` | First local date, inclusive |
| `throughDate` | `String!` | Last local date, inclusive. Must not be before `fromDate`. |

Broadcasts in the window, up to 31 inclusive days and 1000 broadcasts. `truncated` is `true` when more broadcasts exist than were returned; narrow the window and request again.

REST equivalent: `GET …/schedule`.

### `programs`

```graphql theme={null}
programs(fromDate: String!, throughDate: String!): Programs!
```

Same window arguments as `schedule`. Returns the programs in the window with their presenters and regular slots, and echoes `fromDate` and `throughDate`.

REST equivalent: `GET …/programs`.

### `currentBroadcast`

```graphql theme={null}
currentBroadcast: CurrentBroadcast!
```

The live or current-window broadcast. `broadcast` is `null` when nothing is on air. `asOf` is the instant the answer was computed.

REST equivalent: `GET …/broadcast/current`.

### `nowPlaying`

```graphql theme={null}
nowPlaying: NowPlaying!
```

The latest reported track from external automation. `track` is `null` with `freshness.status` `"empty"` when nothing was reported. `elapsedSeconds` and `endsAt` are computed per request and are `null` when the duration is unknown or the track is not playing.

REST equivalent: `GET …/now-playing`.

### `trackHistory`

```graphql theme={null}
trackHistory(cursor: String, limit: Int = 25): TrackHistory!
```

| Argument | Type | Notes |
| - | - | - |
| `cursor` | `String` | The `nextCursor` from the previous page. Opaque. |
| `limit` | `Int` | 1–100, default 25 |

Reported track observations for tracks that were playing, newest first, with cursor pagination. Duplicate titles at different times are separate entries.

REST equivalent: `GET …/track-history`.

### `weather`

```graphql theme={null}
weather: Weather
```

Normalized weather for the station's coverage areas. Returns `null` when weather redistribution is not enabled for the station; there is no error in that case. See [Weather JSON](#weather-json) for the shape of `snapshots`.

REST equivalent: `GET …/weather`.

### `traffic`

```graphql theme={null}
traffic: Traffic
```

Normalized traffic for the station's watched roads. Returns `null` when traffic redistribution is not enabled. See [Traffic JSON](#traffic-json) for the shape of `snapshot`.

REST equivalent: `GET …/traffic`.

## Mutations

### `previewEditorial`

```graphql theme={null}
previewEditorial(input: PreviewInput!): RevisionPreview!
```

Renders a specific revision of a record for a headless site's preview mode. Send it with `POST` and both headers:

```http theme={null}
Authorization: Bearer <tenant token>
X-Preview-Grant: <15-minute preview grant>
```

`input.stationId` must match the station in the URL, otherwise the call returns `NOT_FOUND`. A missing `X-Preview-Grant` returns `UNAUTHENTICATED`. Call it from your server so the grant and token stay out of the browser.

REST equivalent: `POST /api/v1/preview/editorial`.

## Object types

### Station

| Field | Type | Description |
| - | - | - |
| `contractVersion` | `Int!` | Listener contract version, currently `1` |
| `id` | `ID!` | Station identifier used in media URLs and ingest |
| `slug` | `String!` | Public URL slug |
| `name` | `String!` | Display name |
| `timezone` | `String!` | IANA timezone, such as `Europe/Amsterdam` |
| `publishingLanguage` | `String!` | Language of published content |
| `profile` | `StationProfile` | Public station details, or `null` until the station saves a profile |
| `resources` | `StationResources!` | REST paths for each resource |
| `delivery` | `StationDelivery!` | Caching and polling policy |

### StationProfile

| Field | Type | Description |
| - | - | - |
| `tagline` | `String` | One line to show under the station name |
| `description` | `String` | Longer station description |
| `frequencyLabel` | `String` | How listeners tune in, such as `FM 105.3 · DAB+ 8A` |
| `websiteUrl` | `String` | HTTPS URL of the station website |
| `streamUrl` | `String` | HTTPS URL of the live audio stream |
| `contactEmail` | `String` | Public contact email address |
| `phone` | `String` | Public phone number |
| `address` | `StationAddress` | Postal address, or `null` |
| `social` | `[StationSocialLink!]!` | Social profiles, up to 12, possibly empty |

Each nullable field is `null` when the station has not filled it in.

### StationAddress

| Field | Type | Description |
| - | - | - |
| `street` | `String` | Street and number |
| `postalCode` | `String` | Postal code |
| `city` | `String` | City |
| `country` | `String` | Country |

### StationSocialLink

| Field | Type | Description |
| - | - | - |
| `network` | `String!` | `facebook`, `instagram`, `x`, `tiktok`, `youtube`, `linkedin`, `mastodon`, `bluesky`, `whatsapp`, or `other` |
| `url` | `String!` | HTTPS profile URL |

### StationResources

Each field except `realtime` is a path (or path prefix) for the matching REST resource. A `null` value means the resource is disabled, unpublished, or not entitled.

| Field | Type | Description |
| - | - | - |
| `schemas` | `String!` | Content type definitions |
| `content` | `String` | Published content collection |
| `schedule` | `String` | Schedule window |
| `programs` | `String` | Program summaries |
| `broadcast` | `String` | Current broadcast |
| `nowPlaying` | `String` | Now playing |
| `trackHistory` | `String` | Track history |
| `realtime` | `StationRealtime` | Where to follow the station's public realtime feed. `null` while the station is offline or the feed is switched off. |
| `weather` | `String` | Weather. `null` when not entitled. |
| `traffic` | `String` | Traffic. `null` when not entitled. |
| `media` | `String!` | Public media path prefix |

### StationRealtime

The station's public realtime feed. Connect to `url` without a credential. Pass the whole object to the SDK's `subscribePublicFeed`, or let `client.subscribe` read it for you. See [Public realtime feed](/radiotools/api-reference/public-delivery#public-realtime-feed) and the [TypeScript SDK](/radiotools/api-reference/sdks/typescript#realtime-updates).

| Field | Type | Description |
| - | - | - |
| `url` | `String!` | Secure WebSocket (`wss:`) URL of the feed |
| `channel` | `String!` | Channel name carried by feed events; ignore events for other channels |

### StationDelivery

| Field | Type | Description |
| - | - | - |
| `cache` | `String!` | Cache policy for HTTP reads, `no-store` |
| `conditional` | `String!` | Conditional read mechanism, `etag` |
| `pollIntervalSeconds` | `Int!` | Minimum seconds between polls |

### ContentSchema

| Field | Type | Description |
| - | - | - |
| `key` | `String!` | Stable content type key |
| `label` | `String!` | Display name |
| `version` | `Int!` | Schema version. Match a record's `schemaVersion` to it. |
| `archived` | `Boolean!` | `true` when the type no longer accepts new records |
| `fields` | `JSON!` | Array of field definitions. See [Schema field JSON](#schema-field-json). |

### ContentCollection

| Field | Type | Description |
| - | - | - |
| `records` | `[PublishedRecord!]!` | Records on this page. Empty is a valid result. |
| `nextCursor` | `ID` | Pass as `cursor` for the next page. `null` on the last page. |

### PublishedRecord

| Field | Type | Description |
| - | - | - |
| `contractVersion` | `Int!` | Listener contract version |
| `id` | `ID!` | Record UUID |
| `publicationId` | `ID!` | Identifier of this published snapshot |
| `revision` | `Int!` | Revision that is published |
| `type` | `String!` | Content type key |
| `schemaVersion` | `Int!` | Schema version the values were written against |
| `publishedAt` | `String!` | When this live version was published. A republished correction moves it. |
| `firstPublishedAt` | `String!` | When the record first went public. Corrections do not move it. |
| `values` | `JSON!` | Field values keyed by schema field key |
| `document` | `JSON!` | The editorial document, an ordered list of typed blocks. See [Field types and empty states](/radiotools/api-reference/field-types). |
| `media` | `[PublicMedia!]!` | Media referenced by the record |
| `related` | `PublishedRelated` | Planning links of a published `programs` or `presenters` record; `null` for other types |

### PublishedRelated

| Field | Type | Description |
| - | - | - |
| `programIds` | `[ID!]!` | Programs this profile belongs to or presents. Matches `program.id` in `schedule` and `id` in `programs`. |

### PublicMedia

| Field | Type | Description |
| - | - | - |
| `assetId` | `ID!` | Media asset |
| `versionId` | `ID!` | Media version; this is the `id` in anonymous media URLs |
| `kind` | `String!` | `image`, `audio`, or `video` |
| `url` | `String!` | Anonymous URL for the media bytes |
| `credits` | `String!` | Attribution text, possibly empty |
| `mime` | `String!` | MIME type, such as `image/jpeg` |
| `alt` | `String!` | Alternative text, possibly empty |
| `width` | `Int` | Width in pixels, or `null` when unknown or not an image or video |
| `height` | `Int` | Height in pixels, or `null` |
| `duration` | `Float` | Length in seconds for audio and video, or `null` |
| `variants` | `PublicMediaVariants!` | Smaller image renditions |

`url` is the original file. Use `variants` for list pages and `srcset`, and `width` and `height` to reserve the aspect ratio.

### PublicMediaVariants

Each field is a URL, or `null` when the rendition does not exist, for example for audio.

| Field | Type | Description |
| - | - | - |
| `thumb` | `String` | Resized to at most 320 pixels wide |
| `display` | `String` | Resized to at most 1280 pixels wide |
| `card` | `String` | 16:9 crop, at most 1280 pixels wide, centered on the image's focal point |
| `square` | `String` | Square crop, at most 640 pixels wide, centered on the focal point |

### Schedule

| Field | Type | Description |
| - | - | - |
| `contractVersion` | `Int!` | Listener contract version |
| `timezone` | `String!` | Station timezone for the dates |
| `broadcasts` | `[Broadcast!]!` | Broadcasts in the window |
| `truncated` | `Boolean!` | `true` when results were cut off at the limit |

### Broadcast

| Field | Type | Description |
| - | - | - |
| `id` | `ID!` | Broadcast UUID |
| `title` | `String!` | Broadcast title |
| `startsAt` | `String!` | Start instant |
| `endsAt` | `String!` | End instant |
| `timezone` | `String!` | Timezone of `localDate` |
| `localDate` | `String!` | Local calendar date of the broadcast |
| `resolution` | `String!` | How the slot was resolved when schedules overlap or leave gaps |
| `status` | `String!` | `scheduled`, `cancelled`, `live`, or `completed` |
| `cancellationReason` | `String!` | Reason text when cancelled, otherwise empty |
| `repeatOfId` | `ID` | The broadcast this one repeats, or `null` |
| `nature` | `String!` | `live`, `prerecorded`, or `repeat`. Use it to label the slot. |
| `program` | `BroadcastProgram!` | The program |
| `presenters` | `[BroadcastPresenter!]!` | Presenters, possibly empty |

### BroadcastProgram

| Field | Type | Description |
| - | - | - |
| `id` | `ID!` | Program UUID |
| `title` | `String!` | Program title |
| `profileId` | `ID` | Linked profile record, or `null` |

### ProgramSummary

| Field | Type | Description |
| - | - | - |
| `id` | `ID!` | Program UUID |
| `title` | `String!` | Program title |
| `profileId` | `ID` | Linked profile record, or `null` |
| `presenters` | `[BroadcastPresenter!]!` | Published presenter profiles of the program and of its slots in the window |
| `slots` | `[ProgramSlot!]!` | Regular slots whose rule overlaps the window, Monday first, up to 100 |

### ProgramSlot

A regular slot, such as "Saturday 10:00 for 120 minutes, every week".

| Field | Type | Description |
| - | - | - |
| `weekday` | `Int!` | `0` is Sunday through `6`, Saturday |
| `startTime` | `String!` | Wall-clock start time `HH:mm` in `timezone` |
| `durationMinutes` | `Int!` | Length, 1–1440 minutes |
| `timezone` | `String!` | IANA timezone of `startTime` |
| `intervalWeeks` | `Int!` | `1` is every week, `2` every other week, up to `8` |
| `fromDate` | `String!` | First local date of the rule in force |
| `throughDate` | `String!` | Last local date of the rule in force |
| `presenters` | `[BroadcastPresenter!]!` | Presenters of this slot, possibly empty |

### BroadcastPresenter

| Field | Type | Description |
| - | - | - |
| `id` | `ID!` | Presenter UUID |
| `title` | `String!` | Presenter name |

### Programs

| Field | Type | Description |
| - | - | - |
| `contractVersion` | `Int!` | Listener contract version |
| `timezone` | `String!` | Station timezone |
| `fromDate` | `String!` | Window start, echoed |
| `throughDate` | `String!` | Window end, echoed |
| `programs` | `[ProgramSummary!]!` | Programs in the window |
| `truncated` | `Boolean!` | `true` when results were cut off |

### CurrentBroadcast

| Field | Type | Description |
| - | - | - |
| `contractVersion` | `Int!` | Listener contract version |
| `timezone` | `String!` | Station timezone |
| `asOf` | `String!` | Instant the answer was computed |
| `broadcast` | `Broadcast` | Current broadcast, or `null` |

### NowPlaying

| Field | Type | Description |
| - | - | - |
| `contractVersion` | `Int!` | Listener contract version |
| `stationId` | `ID!` | Station identifier |
| `timezone` | `String!` | Station timezone |
| `track` | `PlaybackTrack` | Latest reported track, or `null` |
| `elapsedSeconds` | `Int` | Seconds since the track started, or `null` |
| `endsAt` | `String` | Estimated end instant, or `null` |
| `freshness` | `PlaybackFreshness!` | How current the observation is |

### PlaybackTrack and HistoryEntry

`HistoryEntry` has the same fields as `PlaybackTrack` plus an `id`.

| Field | Type | Description |
| - | - | - |
| `id` | `ID!` | Observation identifier (`HistoryEntry` only) |
| `artist` | `String!` | Artist |
| `title` | `String!` | Track title |
| `album` | `String` | Album, or `null` |
| `durationSeconds` | `Int` | Duration in seconds, or `null` when unknown |
| `artworkUrl` | `String` | HTTPS cover URL. `null` when unknown. |
| `isPlaying` | `Boolean!` | Whether automation reported the track as playing |
| `observedAt` | `String!` | When automation observed the track |
| `receivedAt` | `String!` | When RadioTools received the observation |

### PlaybackFreshness

| Field | Type | Description |
| - | - | - |
| `status` | `String!` | `fresh`, `stale`, or `empty` |
| `thresholdSeconds` | `Int!` | Age after which an observation becomes `stale` |
| `ageSeconds` | `Int` | Age of the observation, or `null` when empty |
| `observedAt` | `String` | Observation instant, or `null` |
| `receivedAt` | `String` | Receipt instant, or `null` |

### TrackHistory

| Field | Type | Description |
| - | - | - |
| `contractVersion` | `Int!` | Listener contract version |
| `stationId` | `ID!` | Station identifier |
| `items` | `[HistoryEntry!]!` | Observations, newest first |
| `nextCursor` | `String` | Pass as `cursor` for the next page, or `null` |

### Weather

| Field | Type | Description |
| - | - | - |
| `stationId` | `ID!` | Station identifier |
| `timezone` | `String!` | Station timezone |
| `snapshots` | `JSON!` | Array of weather snapshots, one per coverage area |

### Traffic

| Field | Type | Description |
| - | - | - |
| `stationId` | `ID!` | Station identifier |
| `snapshot` | `JSON!` | One traffic snapshot |

### RevisionPreview

| Field | Type | Description |
| - | - | - |
| `id` | `ID!` | Record UUID |
| `revision` | `Int!` | Previewed revision |
| `schemaVersion` | `Int!` | Schema version of the values |
| `values` | `JSON!` | Field values |
| `document` | `JSON!` | Editorial document |
| `media` | `[PublicMedia!]!` | Media referenced by the revision |

## Enums

### ContentSort

| Value | Orders by |
| - | - |
| `publishedAt` | First publication (`firstPublishedAt`). Default. |
| `updatedAt` | Publication of the live version (`publishedAt`) |

### SortDirection

| Value | Meaning |
| - | - |
| `asc` | Oldest first |
| `desc` | Newest first. Default. |

### ContentFilterOp

| Value | Matches |
| - | - |
| `eq` | The exact value. Default. |
| `contains` | A case-insensitive substring of a text field |
| `anyOf` | Any of `values`. On a multi-select choice field, any of the record's keys. |

## Input types

### ContentFilter

| Field | Type | Description |
| - | - | - |
| `field` | `String!` | Key of a text or choice delivery field |
| `op` | `ContentFilterOp` | Default `eq` |
| `value` | `String` | Up to 200 characters. Required with `eq` and `contains`; not allowed with `anyOf`. |
| `values` | `[String!]` | 1–20 values, each up to 200 characters. Required with `anyOf`; not allowed otherwise. |

The REST equivalents are `field:value`, `field.contains:value`, and `field.anyOf:a,b`.

### PreviewInput

| Field | Type | Description |
| - | - | - |
| `stationId` | `ID!` | Must match the station in the URL |
| `recordId` | `ID!` | Record to preview |
| `revision` | `Int!` | Revision number |
| `audience` | `String!` | Preview audience label, 1–100 characters |

## JSON scalar shapes

Fields typed `JSON` return arbitrary JSON, so you cannot select sub-fields. Parse them in your client. All units match [Field types and empty states](/radiotools/api-reference/field-types#weather-and-traffic-units).

### Schema field JSON

`ContentSchema.fields` is an array of field definitions:

```json theme={null}
{
  "key": "category",
  "label": "Category",
  "kind": "choices",
  "required": true,
  "choices": [
    { "key": "local", "label": "Local" },
    { "key": "sports", "label": "Sports" }
  ]
}
```

| Property | Present when | Description |
| - | - | - |
| `key`, `label`, `kind`, `required` | Always | Identity, display name, field kind, and whether a value is required |
| `maxLength` | `text` fields with a limit | Maximum characters |
| `min`, `max` | `number` fields with bounds | Allowed range |
| `choices` | `choices` fields | Allowed `{ key, label }` options |
| `multiple` | Multi-select `choices` fields | `true` when the value is an array of keys; `maxItems` caps the selection |
| `targetType` | `content` fields | Content type key the reference points to |
| `maxItems` | Repeating fields | Maximum entries |
| `fields` | `group` fields | Nested definitions, up to three levels deep |

`kind` is one of `text`, `rich_text`, `number`, `boolean`, `datetime`, `choices`, `media`, `content`, or `group`. See [Field types and empty states](/radiotools/api-reference/field-types#published-content-fields) for rendering guidance.

### Weather JSON

`Weather.snapshots` is an array. Each element has this shape:

```json theme={null}
{
  "source": "open-meteo",
  "coverage": {
    "id": "nl:midden",
    "name": "Midden-Nederland",
    "countryCode": "nl",
    "countryName": "Netherlands",
    "latitude": 52.1,
    "longitude": 5.2,
    "timezone": "Europe/Amsterdam"
  },
  "freshness": {
    "status": "fresh",
    "thresholdSeconds": 3600,
    "ageSeconds": 420,
    "fetchedAt": "2026-10-05T08:00:00.000Z",
    "observedAt": "2026-10-05T08:00:00.000Z",
    "lastError": null
  },
  "current": {
    "asOf": "2026-10-05T08:00:00.000Z",
    "conditionCode": "PartlyCloudy",
    "daylight": true,
    "precipitation": 0,
    "temperature": 14.2,
    "temperatureApparent": 12.8,
    "uvIndex": 2,
    "windDirection": 230,
    "windGust": 28,
    "windSpeed": 16
  },
  "hourly": [],
  "daily": []
}
```

The values above are illustrative.

| Property | Description |
| - | - |
| `source` | Weather data provider, currently `open-meteo` |
| `coverage` | The area the snapshot covers |
| `freshness.status` | `fresh`, `stale`, `unavailable`, or `empty` |
| `freshness.ageSeconds`, `fetchedAt`, `observedAt`, `lastError` | `null` when unknown or no error occurred |
| `current` | `null` when no current reading exists |
| `hourly[]` | `forecastStart`, `conditionCode`, `daylight`, `precipitation`, `precipitationChance` (0–1), `temperature`, `temperatureApparent`, `windDirection`, `windGust`, `windSpeed` |
| `daily[]` | `forecastStart`, `conditionCode`, `precipitationAmount`, `precipitationChance` (0–1), `sunriseTime`, `sunsetTime`, `temperatureMax`, `temperatureMin`, `uvIndex`, `windDirection`, `windGust`, `windSpeed` |

`conditionCode` is one of `Clear`, `MostlyClear`, `PartlyCloudy`, `Cloudy`, `Fog`, `Drizzle`, `FreezingDrizzle`, `Rain`, `HeavyRain`, `FreezingRain`, `Snow`, `HeavySnow`, `Flurries`, `Showers`, `SnowShowers`, `Thunderstorm`, or `SevereThunderstorm`.

### Traffic JSON

`Traffic.snapshot` has this shape:

```json theme={null}
{
  "source": "anwb",
  "watch": { "roads": [{ "id": "nl:a2", "code": "A2", "name": "A2" }] },
  "freshness": {
    "status": "fresh",
    "thresholdSeconds": 600,
    "ageSeconds": 90,
    "fetchedAt": "2026-10-05T08:00:00.000Z",
    "observedAt": "2026-10-05T08:00:00.000Z",
    "lastError": null
  },
  "incidents": [
    {
      "id": "incident-1",
      "road": "A2",
      "type": "jam",
      "direction": "southbound",
      "from": "Utrecht",
      "to": "Vianen",
      "lengthMeters": 3200,
      "delaySeconds": 420,
      "summary": "Queue due to heavy traffic",
      "updatedAt": "2026-10-05T07:58:00.000Z"
    }
  ],
  "totals": { "incidentCount": 1, "jamDistanceMeters": 3200, "delaySeconds": 420 }
}
```

The values above are illustrative.

| Property | Description |
| - | - |
| `source` | Traffic data provider, currently `anwb` |
| `watch.roads[]` | The roads the station watches |
| `freshness.status` | `fresh`, `stale`, `unavailable`, or `empty` |
| `incidents[].type` | `jam`, `roadworks`, `closure`, or `speed_check` |
| `incidents[].direction` | `northbound`, `southbound`, `eastbound`, `westbound`, `both`, or `unknown` |
| `incidents[].lengthMeters`, `delaySeconds` | `null` means unknown, not zero |
| `totals` | Aggregates over `incidents` |

An empty `incidents` array with `freshness.status` of `fresh` means no incidents on the watched roads.

## Errors

A failed operation returns HTTP status from the most severe error code, plus a standard GraphQL `errors` array. Branch on `errors[i].extensions.code`.

| Code | HTTP | Meaning |
| - | - | - |
| `UNAUTHENTICATED` | 401 | Missing, expired, revoked, wrong-scope, or cross-station token; or a missing preview grant |
| `FORBIDDEN` | 403 | The credential is valid but may not perform the operation |
| `VALIDATION_ERROR` | 400 | An argument is out of range or malformed. `extensions.issues` lists `{ path, message }`, with an optional `code` and `suggestion`. |
| `NOT_FOUND` | 404 | Resource missing, unpublished, disabled, or the station is offline |
| `METHOD_NOT_ALLOWED` | 405 | Use `GET`, `POST`, or `OPTIONS` |
| `REVISION_CONFLICT` | 409 | A revision or schema version no longer matches |
| `IDEMPOTENCY_CONFLICT` | 409 | A repeated request conflicts with an earlier one |
| `RATE_LIMITED` | 429 | Too many requests; retry later |
| `PROVIDER_UNAVAILABLE` | 503 | An upstream provider is unavailable |
| `INTERNAL_ERROR` | 500 | Unexpected failure |

A switched-off module shows up as a `null` resource and `NOT_FOUND` in GraphQL, not as the REST code `MODULE_DISABLED`.

Errors raised by GraphQL itself, such as an unknown field or a missing required variable, have no `extensions.code`. Fix the query rather than retrying.

When a query asks for several fields and one fails, the response can carry both `data` and `errors`. Check `errors` before trusting `data`.

Requests that fail before the query runs, such as an invalid token or an unknown station, return the REST error body `{ "error": { "code", "message", "requestId" } }`. Every response includes an `x-request-id` header you can quote to support.

## More examples

### Several resources in one request

```graphql theme={null}
query HomePage($from: String!, $through: String!) {
  station {
    name
    timezone
  }
  currentBroadcast {
    broadcast {
      title
      endsAt
      presenters {
        title
      }
    }
  }
  nowPlaying {
    track {
      artist
      title
    }
    freshness {
      status
    }
  }
  schedule(fromDate: $from, throughDate: $through) {
    truncated
    broadcasts {
      title
      startsAt
    }
  }
}
```

### Aliases and fragments

```graphql theme={null}
fragment BroadcastCard on Broadcast {
  id
  title
  startsAt
  endsAt
  program {
    title
  }
}

query TwoDays {
  today: schedule(fromDate: "2026-10-05", throughDate: "2026-10-05") {
    broadcasts {
      ...BroadcastCard
    }
  }
  tomorrow: schedule(fromDate: "2026-10-06", throughDate: "2026-10-06") {
    broadcasts {
      ...BroadcastCard
    }
  }
}
```

### Content types and their fields

```graphql theme={null}
query Types {
  schemas {
    key
    label
    version
    archived
    fields
  }
}
```

Request one type, or a historical version of it:

```graphql theme={null}
query NewsSchema {
  schemas(type: "news", version: 2) {
    key
    version
    fields
  }
}
```

### Programs in a window

```graphql theme={null}
query Programs {
  programs(fromDate: "2026-10-05", throughDate: "2026-10-11") {
    truncated
    programs {
      id
      title
      presenters {
        title
      }
      slots {
        weekday
        startTime
        durationMinutes
        intervalWeeks
      }
    }
  }
}
```

### Weather and traffic

```graphql theme={null}
query Conditions {
  weather {
    timezone
    snapshots
  }
  traffic {
    snapshot
  }
}
```

Both fields are `null` when the station has no entitlement, so handle that before reading the JSON.

### Follow pagination

```js theme={null}
async function fetchAll(runQuery, type) {
  const records = [];
  let cursor = null;

  do {
    const { data, errors } = await runQuery(
      `query Page($type: String!, $cursor: ID) {
        content(type: $type, limit: 50, cursor: $cursor) {
          nextCursor
          records { id publishedAt values }
        }
      }`,
      { type, cursor },
    );
    if (errors?.length) throw new Error(errors[0].message);

    records.push(...data.content.records);
    cursor = data.content.nextCursor;
  } while (cursor);

  return records;
}
```

Stop when `nextCursor` is `null`. Do not reuse a cursor across content types.

### Simple read with GET

```sh theme={null}
curl -sS -G "https://radiotools.rivi.software/api/v1/stations/rivi-fm/graphql" \
  -H "Authorization: Bearer $RADIOTOOLS_LISTENER_TOKEN" \
  --data-urlencode 'query={ station { name } }'
```

Pass `variables` and `extensions` as JSON-encoded strings, and `operationName` as plain text. `GET` runs queries only.

## Full schema

This is the schema definition language (SDL) for the listener API.

```graphql theme={null}
scalar JSON

type Query {
  station: Station!
  schemas(type: String, version: Int): [ContentSchema!]!
  """
  Published records of one type, newest first by default. publishedAt orders by first
  publication, updatedAt by the live version's publication; pass nextCursor to continue.
  """
  content(
    type: String!
    cursor: ID
    limit: Int = 25
    sort: ContentSort = publishedAt
    direction: SortDirection = desc
    filter: [ContentFilter!]
  ): ContentCollection!
  contentRecord(id: ID!): PublishedRecord!
  """
  The record a slug names; a slug it used before resolves to the record as it is now.
  """
  contentBySlug(type: String!, slug: String!): PublishedRecord!
  schedule(fromDate: String!, throughDate: String!): Schedule!
  programs(fromDate: String!, throughDate: String!): Programs!
  currentBroadcast: CurrentBroadcast!
  nowPlaying: NowPlaying!
  trackHistory(cursor: String, limit: Int = 25): TrackHistory!
  weather: Weather
  traffic: Traffic
}

type Mutation {
  previewEditorial(input: PreviewInput!): RevisionPreview!
}

type Station {
  contractVersion: Int!
  id: ID!
  slug: String!
  name: String!
  timezone: String!
  publishingLanguage: String!
  """
  Present once the station has saved a profile.
  """
  profile: StationProfile
  resources: StationResources!
  delivery: StationDelivery!
}

type StationProfile {
  tagline: String
  description: String
  """
  How listeners tune in, for example "FM 105.3 · DAB+ 8A".
  """
  frequencyLabel: String
  websiteUrl: String
  streamUrl: String
  contactEmail: String
  phone: String
  address: StationAddress
  social: [StationSocialLink!]!
}

type StationAddress {
  street: String
  postalCode: String
  city: String
  country: String
}

type StationSocialLink {
  network: String!
  url: String!
}

type StationResources {
  schemas: String!
  content: String
  schedule: String
  programs: String
  broadcast: String
  nowPlaying: String
  trackHistory: String
  """
  The WebSocket carrying the station's public feed rows; null while offline or switched off.
  """
  realtime: StationRealtime
  weather: String
  traffic: String
  media: String!
}

type StationRealtime {
  url: String!
  channel: String!
}

type StationDelivery {
  cache: String!
  conditional: String!
  pollIntervalSeconds: Int!
}

type ContentSchema {
  key: String!
  label: String!
  version: Int!
  archived: Boolean!
  fields: JSON!
}

enum ContentSort {
  publishedAt
  updatedAt
}

enum SortDirection {
  asc
  desc
}

enum ContentFilterOp {
  eq
  contains
  anyOf
}

"""
eq and contains take value; anyOf takes values. Text and choice delivery fields only.
"""
input ContentFilter {
  field: String!
  op: ContentFilterOp = eq
  value: String
  values: [String!]
}

type ContentCollection {
  records: [PublishedRecord!]!
  nextCursor: ID
}

type PublishedRecord {
  contractVersion: Int!
  id: ID!
  publicationId: ID!
  revision: Int!
  type: String!
  schemaVersion: Int!
  publishedAt: String!
  firstPublishedAt: String!
  values: JSON!
  document: JSON!
  media: [PublicMedia!]!
  """
  Planning programmes a published programs/presenters profile belongs to; null for other types.
  """
  related: PublishedRelated
}

type PublishedRelated {
  programIds: [ID!]!
}

type PublicMedia {
  assetId: ID!
  versionId: ID!
  kind: String!
  url: String!
  credits: String!
  mime: String!
  alt: String!
  width: Int
  height: Int
  duration: Float
  variants: PublicMediaVariants!
}

type PublicMediaVariants {
  thumb: String
  display: String
  card: String
  square: String
}

type Schedule {
  contractVersion: Int!
  timezone: String!
  broadcasts: [Broadcast!]!
  truncated: Boolean!
}

type Programs {
  contractVersion: Int!
  timezone: String!
  fromDate: String!
  throughDate: String!
  programs: [ProgramSummary!]!
  truncated: Boolean!
}

type ProgramSummary {
  id: ID!
  title: String!
  profileId: ID
  presenters: [BroadcastPresenter!]!
  slots: [ProgramSlot!]!
}

"""
A regular slot: weekday 0 = Sunday … 6 = Saturday, startTime is wall time in timezone.
"""
type ProgramSlot {
  weekday: Int!
  startTime: String!
  durationMinutes: Int!
  timezone: String!
  intervalWeeks: Int!
  fromDate: String!
  throughDate: String!
  presenters: [BroadcastPresenter!]!
}

type CurrentBroadcast {
  contractVersion: Int!
  timezone: String!
  asOf: String!
  broadcast: Broadcast
}

type NowPlaying {
  contractVersion: Int!
  stationId: ID!
  timezone: String!
  track: PlaybackTrack
  elapsedSeconds: Int
  endsAt: String
  freshness: PlaybackFreshness!
}

type PlaybackTrack {
  artist: String!
  title: String!
  album: String
  durationSeconds: Int
  artworkUrl: String
  isPlaying: Boolean!
  observedAt: String!
  receivedAt: String!
}

type PlaybackFreshness {
  status: String!
  thresholdSeconds: Int!
  ageSeconds: Int
  observedAt: String
  receivedAt: String
}

type TrackHistory {
  contractVersion: Int!
  stationId: ID!
  items: [HistoryEntry!]!
  nextCursor: String
}

type HistoryEntry {
  id: ID!
  artist: String!
  title: String!
  album: String
  durationSeconds: Int
  artworkUrl: String
  isPlaying: Boolean!
  observedAt: String!
  receivedAt: String!
}

type Broadcast {
  id: ID!
  title: String!
  startsAt: String!
  endsAt: String!
  timezone: String!
  localDate: String!
  resolution: String!
  status: String!
  cancellationReason: String!
  repeatOfId: ID
  "live, prerecorded or repeat"
  nature: String!
  program: BroadcastProgram!
  presenters: [BroadcastPresenter!]!
}

type BroadcastProgram {
  id: ID!
  title: String!
  profileId: ID
}

type BroadcastPresenter {
  id: ID!
  title: String!
}

type Weather {
  stationId: ID!
  timezone: String!
  snapshots: JSON!
}

type Traffic {
  stationId: ID!
  snapshot: JSON!
}

input PreviewInput {
  stationId: ID!
  recordId: ID!
  revision: Int!
  audience: String!
}

type RevisionPreview {
  id: ID!
  revision: Int!
  schemaVersion: Int!
  values: JSON!
  document: JSON!
  media: [PublicMedia!]!
}
```


## Related topics

- [GraphQL](/radiotools/api-reference/graphql.md)
- [Query with GraphQL](/radiotools/api-reference/public-listener/query-graphql.md)
- [Query with GraphQL (GET)](/radiotools/api-reference/public-listener/query-graphql-get.md)
- [Errors and versioning](/radiotools/api-reference/errors-and-versioning.md)
- [API overview](/radiotools/api-reference/overview.md)


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