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

# Requests and response handling

> Make your first request, follow pagination, handle conditional reads, and distinguish JSON from media and calendar responses.

## Make your first request

Run these requests from your consumer server. Replace the fictional station slug and set `RADIOTOOLS_LISTENER_TOKEN` to a credential with `listener:read`.

```sh theme={null}
BASE="https://radiotools.rivi.software"
STATION_SLUG="radio-rivier"

curl --fail-with-body -sS -i \
  "$BASE/api/v1/stations/$STATION_SLUG" \
  -H "Authorization: Bearer $RADIOTOOLS_LISTENER_TOKEN" \
  -H "Accept: application/json"
```

The station document gives you its `id`, `timezone`, `resources`, and `delivery` policy. A **slug** selects the station in listener URLs; the **ID** identifies the station in media URLs and ingest requests. They are separate values.

The operation pages include selectable **fictional examples**, including empty and unavailable states. They illustrate the documented shape; they are not live responses or credentials. Replace all sample identifiers with values from your own station. The schema still defines required properties, enums, formats, and bounds.

## Match your parser to the response

| Response | Body | Handling |
| - | - | - |
| Listener `200` JSON | Object containing the endpoint's documented fields | Parse JSON; check the endpoint's contract version and empty-state fields |
| Conditional `304` | No body | Reuse the previous representation; do not call `response.json()` |
| Listener error | `{ "error": { "code", "message", "requestId" } }` | Branch on `error.code`; validation errors may include `issues` |
| Playback ingest `200` | `{ "accepted": true, "outcome": "recorded" }` (illustrative) | Inspect both fields; the outcome describes observation/follow handling |
| Playback ingest error | `{ "error": "Human-readable summary" }` | Handle this legacy shape separately |
| Schedule `.ics` `200` | iCalendar text | Parse calendar text rather than JSON |
| Public media `200` / `206` | Media bytes | Read as a stream, blob, or byte buffer |
| GraphQL `200` | `{ "data", "errors" }` | Check `errors` even when `data` is present |
| Push device `200` | Body unspecified in the imported contract | Use HTTP status as the documented result; do not assume a JSON acknowledgment |

`contractVersion: 1` belongs to listener JSON projections. Media bytes, calendar text, push acknowledgments, and playback-ingest outcomes do not share that envelope.

## Conditional reads

Save the ETag returned by a successful listener read. On the next request, send it unchanged in `If-None-Match`, together with the listener credential:

```sh theme={null}
curl -sS -i "$BASE/api/v1/stations/$STATION_SLUG" \
  -H "Authorization: Bearer $RADIOTOOLS_LISTENER_TOKEN" \
  -H 'If-None-Match: "<etag-from-your-previous-response>"'
```

The following server-side helper handles `304` before reading JSON and keeps previous data scoped to one URL and credential. Create a new reader when either changes.

```js theme={null}
function createListenerReader(url, token) {
  let previous;
  let etag;

  return async function read() {
    const headers = { Authorization: `Bearer ${token}` };
    if (etag) headers["If-None-Match"] = etag;

    const response = await fetch(url, { headers, cache: "no-store" });
    if (response.status === 304 && previous !== undefined) return previous;

    if (!response.ok) {
      // Clear old data when the station becomes unavailable or access is revoked.
      previous = undefined;
      etag = undefined;
      const body = await response.json().catch(() => null);
      const code = body?.error?.code ?? `HTTP_${response.status}`;
      const requestId = response.headers.get("x-request-id");
      throw new Error(`${code}; requestId=${requestId ?? "unknown"}`);
    }

    const body = await response.json();
    if (body.contractVersion !== 1) throw new Error("Unsupported listener contract version");
    previous = body;
    etag = response.headers.get("etag");
    return body;
  };
}
```

Do not poll faster than the station document's `delivery.pollIntervalSeconds`. `no-store` prevents shared caching; ETags still allow an authenticated conditional read. Stop displaying old station data after a revoked credential or an offline response.

## Retrieve a content page

Discover a type with `GET …/schemas`, then request its published records:

```sh theme={null}
curl --fail-with-body -sS --get \
  "$BASE/api/v1/stations/$STATION_SLUG/content" \
  -H "Authorization: Bearer $RADIOTOOLS_LISTENER_TOKEN" \
  --data-urlencode "type=news" \
  --data-urlencode "limit=25" \
  --data-urlencode "filter=category:local"
```

`news` and `category` are fictional schema keys. Use a published type and a text or choice delivery field from your station. Besides an exact match, filters accept `category.contains:loc` for a case-insensitive substring and `category.anyOf:local,sport` for any listed value. Add `sort=updatedAt` to order by latest update, and `direction=asc` for oldest first.

Read `records`, then pass the returned `nextCursor` unchanged on the next request with the **same type, filters, and sort**. Stop when it is `null`. Content pages accept a maximum limit of **50**; reported track history accepts **100**. Both default to **25**. Cursors are endpoint-specific; do not reuse a content cursor for track history.

```sh theme={null}
curl --fail-with-body -sS --get \
  "$BASE/api/v1/stations/$STATION_SLUG/content" \
  -H "Authorization: Bearer $RADIOTOOLS_LISTENER_TOKEN" \
  --data-urlencode "type=news" \
  --data-urlencode "filter=category:local" \
  --data-urlencode "cursor=<nextCursor-from-the-previous-content-page>"
```

## Request a schedule window

```sh theme={null}
curl --fail-with-body -sS --get \
  "$BASE/api/v1/stations/$STATION_SLUG/schedule" \
  -H "Authorization: Bearer $RADIOTOOLS_LISTENER_TOKEN" \
  --data-urlencode "fromDate=2026-10-05" \
  --data-urlencode "throughDate=2026-10-11"
```

These dates are inclusive. Use a window of at most **31 days**. When `truncated` is `true`, split the window into smaller requests. Interpret `localDate` in the response's `timezone`, and use the timestamp fields for actual instants.

## Read published media

Use the media URL returned with the published record. It uses the media **version ID**, not the asset ID, and requires no listener credential:

```sh theme={null}
curl --fail-with-body -sS \
  "$BASE/api/media/public/<stationId>/<versionId>" \
  -H "Range: bytes=0-1023" \
  --output media-fragment.bin
```

A satisfiable single range returns `206 Partial Content`; inspect `Content-Range`. A full read returns `200`. Image variants are `thumb`, `display`, `card`, and `square`; use the URLs in the media item's `variants`. See [Public delivery](/radiotools/api-reference/public-delivery) for resource gates and the realtime feed, and [Field types and empty states](/radiotools/api-reference/field-types) for interpreting JSON values.


## Related topics

- [Field types and empty states](/radiotools/api-reference/field-types.md)
- [API overview](/radiotools/api-reference/overview.md)
- [Public delivery](/radiotools/api-reference/public-delivery.md)
- [Other languages](/radiotools/api-reference/sdks/other-languages.md)
- [TypeScript SDK](/radiotools/api-reference/sdks/typescript.md)


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