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

> Query published station data with one request, and try queries in the browser.

export const GraphQLPlayground = () => {
  const apiOrigin = "https://radiotools.rivi.software";
  const presets = [{
    label: "Station",
    query: `query Station {
  station {
    id
    slug
    name
    timezone
    resources {
      content
      schedule
      nowPlaying
    }
    delivery {
      pollIntervalSeconds
    }
  }
}`,
    variables: "{}"
  }, {
    label: "Now playing",
    query: `query OnAirTrack {
  nowPlaying {
    track {
      artist
      title
      artworkUrl
      observedAt
    }
    freshness {
      status
      ageSeconds
    }
  }
  trackHistory(limit: 5) {
    items {
      artist
      title
      observedAt
    }
    nextCursor
  }
}`,
    variables: "{}"
  }, {
    label: "Schedule",
    query: `query Week($from: String!, $through: String!) {
  schedule(fromDate: $from, throughDate: $through) {
    timezone
    truncated
    broadcasts {
      title
      startsAt
      endsAt
      status
      program {
        title
      }
    }
  }
}`,
    variables: `{
  "from": "2026-10-05",
  "through": "2026-10-11"
}`
  }, {
    label: "Content",
    query: `query News($type: String!) {
  content(type: $type, limit: 5) {
    nextCursor
    records {
      id
      publishedAt
      values
    }
  }
}`,
    variables: `{
  "type": "news"
}`
  }, {
    label: "On air",
    query: `query OnAir {
  currentBroadcast {
    asOf
    broadcast {
      title
      startsAt
      endsAt
      presenters {
        title
      }
    }
  }
}`,
    variables: "{}"
  }, {
    label: "Content types",
    query: `query Types {
  schemas {
    key
    label
    version
    fields
  }
}`,
    variables: "{}"
  }, {
    label: "Weather & traffic",
    query: `query Conditions {
  weather {
    timezone
    snapshots
  }
  traffic {
    snapshot
  }
}`,
    variables: "{}"
  }];
  const fieldClass = "w-full rounded-md border border-zinc-950/10 bg-transparent px-3 py-2 text-sm font-normal dark:border-white/10";
  const codeClass = `${fieldClass} font-mono leading-relaxed`;
  const bearer = token => {
    const value = token.trim();
    return (/^bearer\s/i).test(value) ? value : `Bearer ${value}`;
  };
  const [slug, setSlug] = useState("rivi-fm");
  const [token, setToken] = useState("");
  const [query, setQuery] = useState(presets[0].query);
  const [variables, setVariables] = useState(presets[0].variables);
  const [running, setRunning] = useState(false);
  const [error, setError] = useState(null);
  const [result, setResult] = useState(null);
  const choosePreset = label => {
    const preset = presets.find(item => item.label === label);
    if (!preset) return;
    setQuery(preset.query);
    setVariables(preset.variables);
  };
  const run = async event => {
    event.preventDefault();
    setError(null);
    setResult(null);
    let parsedVariables = {};
    if (variables.trim()) {
      try {
        parsedVariables = JSON.parse(variables);
      } catch {
        setError("Variables must be valid JSON.");
        return;
      }
    }
    setRunning(true);
    const started = performance.now();
    try {
      const response = await fetch(`${apiOrigin}/api/v1/stations/${encodeURIComponent(slug.trim())}/graphql`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: bearer(token)
        },
        body: JSON.stringify({
          query,
          variables: parsedVariables
        })
      });
      const text = await response.text();
      let body = text;
      try {
        body = JSON.stringify(JSON.parse(text), null, 2);
      } catch {}
      setResult({
        status: response.status,
        statusText: response.statusText,
        durationMs: Math.round(performance.now() - started),
        body
      });
    } catch {
      setError("The request did not complete. Check your network connection, the station slug, and that the API origin is reachable.");
    } finally {
      setRunning(false);
    }
  };
  const canRun = slug.trim() !== "" && token.trim() !== "" && !running;
  return <form onSubmit={run} className="not-prose my-6 space-y-4 rounded-xl border border-zinc-950/10 p-4 dark:border-white/10">
      <div className="grid gap-4 sm:grid-cols-2">
        <label className="block space-y-1 text-sm font-medium">
          <span>Station slug</span>
          <input className={fieldClass} value={slug} onChange={event => setSlug(event.target.value)} autoComplete="off" spellCheck={false} />
        </label>
        <label className="block space-y-1 text-sm font-medium">
          <span>
            Listener token (<code>listener:read</code>)
          </span>
          <input className={fieldClass} type="password" value={token} onChange={event => setToken(event.target.value)} placeholder="rtm_…" autoComplete="off" spellCheck={false} />
        </label>
      </div>
      <p className="text-xs text-zinc-500 dark:text-zinc-400">
        The token stays in this page's memory and is sent only to{" "}
        <code>{apiOrigin}</code>. Use a short-lived credential for a test
        station, and revoke it afterwards. Never paste a production token on a
        shared screen.
      </p>

      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm font-medium">Examples:</span>
        {presets.map(preset => <button key={preset.label} type="button" onClick={() => choosePreset(preset.label)} className="rounded-md border border-zinc-950/10 px-2.5 py-1 text-xs hover:bg-zinc-950/5 dark:border-white/10 dark:hover:bg-white/5">
            {preset.label}
          </button>)}
      </div>

      <label className="block space-y-1 text-sm font-medium">
        <span>Query</span>
        <textarea className={codeClass} rows={14} value={query} onChange={event => setQuery(event.target.value)} spellCheck={false} />
      </label>
      <label className="block space-y-1 text-sm font-medium">
        <span>Variables (JSON)</span>
        <textarea className={codeClass} rows={4} value={variables} onChange={event => setVariables(event.target.value)} spellCheck={false} />
      </label>

      <button type="submit" disabled={!canRun} className="rounded-md bg-primary-dark px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
        {running ? "Running…" : "Run query"}
      </button>

      {error ? <p role="alert" className="text-sm text-red-600 dark:text-red-400">
          {error}
        </p> : null}

      {result ? <div className="space-y-2" aria-live="polite">
          <p className="text-sm">
            <strong>
              {result.status} {result.statusText}
            </strong>{" "}
            <span className="text-zinc-500 dark:text-zinc-400">
              · {result.durationMs} ms
            </span>
          </p>
          <pre className="max-h-96 overflow-auto rounded-md border border-zinc-950/10 p-3 text-xs dark:border-white/10">
            {result.body}
          </pre>
        </div> : null}
    </form>;
};

The listener GraphQL endpoint returns the same **published** data as the HTTP API. Use it when a page needs several resources at once, such as the station, now-playing track, and today's schedule, and you want to choose the fields.

## Endpoint

```http theme={null}
POST /api/v1/stations/{stationSlug}/graphql
Content-Type: application/json
Authorization: Bearer <tenant token>
```

* Use a credential with **`listener:read`**. See [Authentication](/radiotools/api-reference/authentication) for the `Bearer ` prefix.
* `POST` takes a JSON body with `query` and optional `variables`, `operationName`, and `extensions`. See [Query GraphQL](/radiotools/api-reference/public-listener/query-graphql).
* `GET` takes the same fields as URL parameters. `variables` and `extensions` are JSON-encoded strings. See [Query GraphQL with GET](/radiotools/api-reference/public-listener/query-graphql-get).
* `OPTIONS` answers CORS preflight.
* Responses are `Cache-Control: no-store`. GraphQL has no ETag layer; use the [HTTP resources](/radiotools/api-reference/public-delivery) when you need conditional reads.
* Unknown, inactive, or public-offline stations return HTTP `404` before the query runs.
* Keep the token on your server. Browser clients should call your own origin, which forwards the request.

## Make a request

```sh theme={null}
curl -sS "https://radiotools.rivi.software/api/v1/stations/rivi-fm/graphql" \
  -H "Authorization: Bearer $RADIOTOOLS_LISTENER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ station { name timezone delivery { pollIntervalSeconds } } }"}'
```

```js theme={null}
const response = await fetch(
  "https://radiotools.rivi.software/api/v1/stations/rivi-fm/graphql",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RADIOTOOLS_LISTENER_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      query: `query Week($from: String!, $through: String!) {
        schedule(fromDate: $from, throughDate: $through) {
          broadcasts { title startsAt endsAt }
        }
      }`,
      variables: { from: "2026-10-05", through: "2026-10-11" },
    }),
  },
);
const { data, errors } = await response.json();
```

The same read with `GET`. Let `curl` URL-encode each parameter:

```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=query Week($from: String!, $through: String!) { schedule(fromDate: $from, throughDate: $through) { broadcasts { title startsAt } } }' \
  --data-urlencode 'variables={"from":"2026-10-05","through":"2026-10-11"}'
```

`GET` runs queries only. Send the `previewEditorial` mutation with `POST`.

## Playground

Run queries against the live API with a credential for your own station. The preset examples use the fictional `rivi-fm` slug, so replace it with your station's slug.

<GraphQLPlayground />

The playground sends the request directly from your browser to the RadioTools API. Nothing is stored, and the token is cleared when you leave the page. Listener queries only read published data.

## Queries

The table summarizes each field. The [GraphQL schema reference](/radiotools/api-reference/graphql-reference) documents every argument, type, enum, error code, and JSON shape.

| Field | Returns | Bounds |
| - | - | - |
| `station` | Identity, `profile`, `resources`, and `delivery` policy | |
| `schemas(type, version)` | Published content type definitions | `version` requires `type` |
| `content(type, cursor, limit, sort, direction, filter)` | Published records and `nextCursor`, newest first by default | `limit` 1–50, default 25; up to 10 filters |
| `contentRecord(id)` | One published record with `values`, `document`, and `media` | |
| `contentBySlug(type, slug)` | The published record a slug names, including a slug it used before | `slug` up to 200 characters |
| `schedule(fromDate, throughDate)` | Broadcasts in an inclusive date window | At most 31 days |
| `programs(fromDate, throughDate)` | Programs in that window, with presenters and regular slots | At most 31 days |
| `currentBroadcast` | The current public broadcast, or `broadcast: null` | |
| `nowPlaying` | Reported track, timing, and `freshness` | |
| `trackHistory(cursor, limit)` | Reported track observations and `nextCursor` | `limit` up to 100, default 25 |
| `weather` | Normalized snapshots, or `null` when not entitled | |
| `traffic` | Normalized snapshot, or `null` when not entitled | |

Dates are local `YYYY-MM-DD` strings. `values`, `document`, `fields`, and the weather and traffic snapshots use a `JSON` scalar, so you cannot select sub-fields inside them. Media bytes are not inlined; use `PublicMedia.url`, or a smaller rendition from `PublicMedia.variants`.

`content` accepts the same `sort`, `direction`, and field filters as the HTTP collection. See [Requests and response handling](/radiotools/api-reference/response-handling).

`nowPlaying` and `trackHistory` reflect encoder observations, not the planned rundown. `artworkUrl` is omitted when unknown.

## Example queries

Published records for one content type, newest first:

```graphql theme={null}
query NewsPage($cursor: ID) {
  content(type: "news", limit: 10, cursor: $cursor) {
    nextCursor
    records {
      id
      publishedAt
      firstPublishedAt
      values
    }
  }
}
```

Filter and sort records. Several filters must all match:

```graphql theme={null}
query RegionalSport {
  content(
    type: "news"
    sort: updatedAt
    direction: desc
    filter: [
      { field: "category", value: "sports" }
      { field: "regions", op: anyOf, values: ["noord", "zuid"] }
    ]
  ) {
    nextCursor
    records {
      id
      values
    }
  }
}
```

A record by its slug, with media for a responsive image:

```graphql theme={null}
query NewsArticle($slug: String!) {
  contentBySlug(type: "news", slug: $slug) {
    id
    revision
    firstPublishedAt
    publishedAt
    values
    document
    media {
      versionId
      kind
      url
      alt
      width
      height
      variants {
        thumb
        display
        card
        square
      }
    }
  }
}
```

A slug the record used before still resolves. Compare `values.slug` with the slug you requested, and redirect to the current URL when they differ. Use `contentRecord(id:)` when you already have the record ID.

Now playing with recent tracks:

```graphql theme={null}
query OnAirTrack {
  nowPlaying {
    track {
      artist
      title
      artworkUrl
      isPlaying
      observedAt
    }
    freshness {
      status
      ageSeconds
    }
  }
  trackHistory(limit: 20) {
    items {
      id
      artist
      title
      observedAt
    }
    nextCursor
  }
}
```

## Errors

Failures return a standard GraphQL `errors` array. Branch on `errors[0].extensions.code`, which uses the same stable codes as the REST API, and read `issues` for validation details.

```json theme={null}
{
  "errors": [
    {
      "message": "Invalid arguments",
      "extensions": {
        "code": "VALIDATION_ERROR",
        "issues": [{ "path": "limit", "message": "Too big: expected number to be <=50" }]
      }
    }
  ]
}
```

Each issue has a `path` and a `message`. An issue can also carry a stable `code` and a `suggestion`, a value you could send instead.

The HTTP status follows the most severe error code, for example `400` for `VALIDATION_ERROR`, `401` for `UNAUTHENTICATED`, `404` for `NOT_FOUND`, and `429` for `RATE_LIMITED`. A resource that is disabled for the station returns `NOT_FOUND`, except `weather` and `traffic`, which return `null`. See [Errors and versioning](/radiotools/api-reference/errors-and-versioning).

## Editorial preview

The `previewEditorial` mutation renders a draft revision for a headless site's preview mode. Send it with `POST`. It needs the listener Bearer token **and** an `X-Preview-Grant` header holding a 15-minute preview grant. The playground does not send that header; use it from your server.

```http theme={null}
POST /api/v1/stations/{stationSlug}/graphql
Authorization: Bearer <tenant token>
X-Preview-Grant: <preview grant>
Content-Type: application/json
```

```graphql theme={null}
mutation Preview($input: PreviewInput!) {
  previewEditorial(input: $input) {
    revision
    values
    document
    media {
      url
    }
  }
}
```

`PreviewInput` takes `stationId`, `recordId`, `revision`, and `audience`. Never put the grant in a URL.

## Live updates

GraphQL has no subscriptions. For live now-playing, on-air, schedule, and content changes, follow the station's public realtime feed, which `station.resources.realtime` describes. The [TypeScript SDK](/radiotools/api-reference/sdks/typescript#realtime-updates) subscribes for you. See [Public realtime feed](/radiotools/api-reference/public-delivery#public-realtime-feed).

## Introspection

The endpoint accepts standard introspection queries, so GraphQL clients and code generators can read the schema from it. The [TypeScript SDK](/radiotools/api-reference/sdks/typescript) provides typed helpers for the same operations; set `transport: "graphql"` to use them.


## Related topics

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


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