# Automating a RadioCult schedule

You are helping a radio station stop building its schedule by hand. The RadioCult API can create, update and delete events, set up repeating series and book artists onto shows, so a schedule that lives somewhere else (a roster spreadsheet, a CMS, a booking form, another calendar) can be pushed into the station instead of retyped.

This skill covers two situations:

1. **Direct changes**: the user asks you to make the changes for them, for example "book Sarah in every Thursday at 8pm until Christmas". You call the API yourself, following the workflow below.
2. **Building a sync**: the user wants a script or scheduled job that keeps RadioCult in step with their own source of truth. The same endpoint rules apply; use the patterns below as the foundation.

Before writing code, check the live API reference for up-to-date endpoint paths and request shapes: https://www.radiocult.fm/docs/api#schedule

## Before you start

- **Writing to the schedule needs Connect**, which is included on Premium and Business plans and can be added to Starter and Growth. A write that returns `402 Payment Required` means either Connect is not enabled for the station or the station's plan is not active, so tell the user to check both. It does not mean the key is wrong.
- **Schedule writes use the secret API key** (starts with `sk_`, from https://app.radiocult.fm/settings/cms/api). The publishable key (`pk_`) covers only the public reads such as the schedule range, live now and artists. Reading your media library to resolve playlist and track IDs is a Connect endpoint, so it needs the secret key too, even though it only reads. Reference the secret key through an environment variable (for example `RADIOCULT_SECRET_KEY`) so it stays out of shell history, logs and code. Never put it in client-side code, never commit it, and never echo it back to the user.
- **You need the station ID**, also on that settings page.

The base URL is `https://api.radiocult.fm`. Authenticate every request with the `x-api-key` header.

## The four rules that bite

**1. Nothing stops you double-booking.** The API does not reject overlapping events. If you create a show in a slot that is already taken, you get two events on top of each other and the station finds out on air. Always read the range you are about to write into first, and reconcile against what is there.

**2. Times go in as UTC, but the station thinks in local time.** `startDateUtc` is a UTC instant. The event is stored with the station's configured timezone, which is what the dashboard and the series recurrence use. When the user says "Thursday at 8pm" they mean 8pm in the station's timezone, so convert explicitly rather than using the machine's local clock, and re-check around daylight saving changes.

**3. An occurrence's `id` is not the ID you write to.** When the schedule range expands a repeating series, each occurrence comes back with a generated `id` in the form `<seriesId>-r_index-<startDateUtc>`. That value does not exist as a record, so using it in an update or delete URL returns a 404. The real, writable ID is `originalId`, which every event carries. Details below.

**4. Deletes are permanent, and `scope` decides how much goes.** A `scope=all` delete on a recurring event removes the entire series, not the one show the user was thinking of. Confirm the blast radius with the user before any destructive or bulk operation.

## Reading the current schedule

Everything starts here. The range query returns each occurrence of a recurring series as its own event, so you never expand recurrence yourself.

```
GET /api/station/:stationId/schedule?startDate=<iso>&endDate=<iso>
```

`startDate` and `endDate` are required ISO timestamps with an explicit UTC offset (for example `2026-08-03T00:00:00Z`). The response is `{ success: true, schedules: Event[] }`, sorted by start time.

Each event carries `id`, `originalId`, `title`, `startDateUtc`, `endDateUtc`, `duration`, `timezone`, `isRecurring` and `artistIds`. Add `expand=artist` if you need artist names rather than IDs.

### Use `originalId`, never `id`, when you write

This is the single most common way a schedule integration breaks, so get it right before you write anything.

A repeating series is stored once and expanded into occurrences at read time. Each expanded occurrence is given a synthetic `id` so it is unique in the response:

```
id:         "8f3c…-r_index-2026-08-06T19:00:00.000Z"   <- generated, not a record
originalId: "8f3c…"                                     <- the series, writable
```

Update and delete look the event up by its exact ID, so the synthetic value matches nothing and the request fails with a 404 "Schedule not found". Standalone events have `originalId` equal to `id`, so **using `originalId` is always correct** and you never need to branch on `isRecurring`.

The other field you need is `instanceStart`, when the scope is `this` or `thisAndFollowing`. That is the occurrence's own `startDateUtc` from the range response, which is exactly the time embedded in the synthetic `id`. Read it; do not compute it.

```js
// Target one show in a repeating series
const occurrence = schedules.find(
  (event) => event.title === 'Breakfast Show' && event.startDateUtc === target
);

const url = `https://api.radiocult.fm/api/station/${stationId}/schedule/${occurrence.originalId}`;
const body = { scope: 'this', instanceStart: occurrence.startDateUtc };
```

Note that this makes `id` unstable across requests for a repeating show: if the series moves, the occurrence's `id` changes with it. Never store `id` as a foreign key in your own system. Store `originalId`, plus the occurrence start if you are tracking a single show.

Two further fields describe the series rather than the occurrence: `scheduleRangeStartUtc` and `scheduleRangeEndUtc` are the first start and last end of the whole series, so a forever-repeating show ends roughly a century out. Don't mistake them for this show's times, which are `startDateUtc` and `endDateUtc`.

## Creating an event

```
POST /api/station/:stationId/schedule
```

Required: `title`, `startDateUtc`, `duration`, `media`. The body is validated strictly, so an unrecognised field fails the whole request rather than being ignored.

- **`duration`** is in **minutes**, between 1 and 1440. There is no `endDateUtc` on the way in; the end is derived.
- **`media`** says what actually plays. The common cases:
  - `{ type: 'live' }` for a show a presenter streams in live. Add `fallback: { type: 'playlist', playlistId }` to cover them not connecting.
  - `{ type: 'playlist', playlistId }` to play a playlist.
  - `{ type: 'mix', trackId }` to play a single pre-recorded file.
  - `{ type: 'relay', relayId }` to relay another stream.
  - Playlist, track and relay IDs are validated, so resolve real IDs first (`GET /api/station/:stationId/media/playlist`, `/api/station/:stationId/media/track`) rather than guessing. Note the paths are singular.
  - Relays are the exception: they cannot be listed over the API, so ask the user to copy the relay's ID from their dashboard. Do not guess one, because an ID that does not exist fails the whole create.
- **`artistIds`** books presenters onto the show, maximum 10.
- **`color`** is optional but must be one of the palette values in the reference; any other hex is rejected.
- **`description`** takes either a plain string or TipTap `JSONContent`, but not HTML. A string is stored as a single paragraph, so send `JSONContent` if you need links or multiple paragraphs. Reads always give you `JSONContent` back.
- **`doRecord: true`** records the show.

```js
const createEvent = async ({ stationId, secretKey, event }) => {
  const response = await fetch(
    `https://api.radiocult.fm/api/station/${stationId}/schedule`,
    {
      method: 'POST',
      headers: {
        'x-api-key': secretKey,
        'content-type': 'application/json',
      },
      body: JSON.stringify(event),
    }
  );

  if (!response.ok) {
    throw new Error(
      `Create failed (${response.status}): ${await response.text()}`
    );
  }

  const { schedule } = await response.json();
  return schedule;
};

await createEvent({
  stationId,
  secretKey,
  event: {
    title: 'Breakfast Show',
    // 08:00 in the station's timezone, expressed as UTC
    startDateUtc: '2026-08-03T07:00:00Z',
    duration: 120,
    media: { type: 'live' },
    artistIds: ['artist-id'],
    doRecord: true,
  },
});
```

## Repeating series

Pass an `rrule` to make the event a series. Omit it for a one-off. It takes an [RFC 5545](https://www.rfc-editor.org/info/rfc5545/) recurrence rule, **pattern only**: no `DTSTART`, because the series is anchored to `startDateUtc`.

Only the patterns the dashboard can produce are accepted:

| Pattern                                  | Example                                     |
| ---------------------------------------- | ------------------------------------------- |
| Daily                                    | `FREQ=DAILY`                                |
| Every N days                             | `FREQ=DAILY;INTERVAL=2`                     |
| Weekly, one or more days                 | `FREQ=WEEKLY;BYDAY=MO,WE;WKST=SU`           |
| Monthly on a day of the month            | `FREQ=MONTHLY;BYMONTHDAY=15;WKST=SU`        |
| Monthly on a weekday of the month        | `FREQ=MONTHLY;BYSETPOS=2;BYDAY=TU;WKST=SU`  |
| Monthly on the last weekday of the month | `FREQ=MONTHLY;BYSETPOS=-1;BYDAY=FR;WKST=SU` |
| Bounded series                           | append `;UNTIL=20261225T000000Z`            |

Rules to respect, because anything else is rejected outright:

- `FREQ` must be `DAILY`, `WEEKLY` or `MONTHLY`. No `YEARLY` or `HOURLY`.
- **No `COUNT`.** To bound a series use `UNTIL`. Omit both to repeat indefinitely.
- `INTERVAL` is 1 to 99 for daily and weekly, but **only 1 or 2 for monthly** (monthly or bimonthly). `INTERVAL=2` combines with any monthly shape, so the last Friday of every other month is `FREQ=MONTHLY;INTERVAL=2;BYSETPOS=-1;BYDAY=FR;WKST=SU`.
- Weekly needs at least one `BYDAY`. Monthly needs exactly one of `BYMONTHDAY` or (`BYSETPOS` + `BYDAY`), never both.
- **Monthly patterns must match `startDateUtc`.** `BYMONTHDAY=15` with a start date on the 12th is rejected. Derive the by-parts from the start date rather than asking the user for them twice.
- `BYSETPOS` is `1` to `5` for a fixed occurrence, or `-1` for the last of that weekday in the month. `-1` needs a `startDateUtc` that is the last such weekday, so "last Friday of the month" means picking the month's final Friday as the start date. Prefer `-1` over `BYSETPOS=5`, which only fires in the months that happen to have five of that weekday.
- Use `WKST=SU` on weekly and monthly rules.
- No `BYMONTH`, `BYWEEKNO`, `BYYEARDAY` or time-of-day by-parts.

```js
// A resident every Thursday from 20:00 station time, until Christmas
await createEvent({
  stationId,
  secretKey,
  event: {
    title: 'Thursday Residency',
    startDateUtc: '2026-08-06T19:00:00Z',
    duration: 120,
    media: { type: 'live' },
    artistIds: ['artist-id'],
    rrule: 'FREQ=WEEKLY;BYDAY=TH;WKST=SU;UNTIL=20261225T000000Z',
  },
});
```

## Updating and deleting

Both take a `scope`, and getting it wrong is how automations wreck a schedule.

| Scope              | What it hits                                                  | Needs `instanceStart` |
| ------------------ | ------------------------------------------------------------- | --------------------- |
| `all`              | The whole series. **Always use this for a standalone event.** | No                    |
| `this`             | One occurrence of a series                                    | Yes                   |
| `thisAndFollowing` | One occurrence and everything after it                        | Yes                   |

`:scheduleId` in both URLs is the event's `originalId`, not the `id` of the occurrence you read. `instanceStart` is the `startDateUtc` of that occurrence as returned by the range query. Do not compute it yourself; read it.

```
PUT    /api/station/:stationId/schedule/:scheduleId
DELETE /api/station/:stationId/schedule/:scheduleId?scope=<scope>&instanceStart=<iso>
```

The update body carries `scope`, optional `instanceStart`, and whichever of `title`, `description`, `color`, `media` and `doRecord` you are changing. Only send the fields you mean to change.

```js
// Move one week's Breakfast Show to a stand-in playlist, leaving the series alone
await fetch(
  `https://api.radiocult.fm/api/station/${stationId}/schedule/${occurrence.originalId}`,
  {
    method: 'PUT',
    headers: { 'x-api-key': secretKey, 'content-type': 'application/json' },
    body: JSON.stringify({
      scope: 'this',
      instanceStart: '2026-08-13T07:00:00Z',
      title: 'Breakfast Show (best of)',
      media: { type: 'playlist', playlistId },
    }),
  }
);
```

Ending a residency is `thisAndFollowing` from the first show that should not happen, not `all`, which would erase every past occurrence of the series too.

## Booking artists onto events

```
PUT    /api/station/:stationId/schedule/:scheduleId/artist/:artistId
DELETE /api/station/:stationId/schedule/:scheduleId/artist/:artistId
```

`:scheduleId` is again the event's `originalId`. These apply to the **whole series**, not a single occurrence. To give one week a guest host instead, update that instance's title with `scope: 'this'` and leave the series booking alone, or split the series with `thisAndFollowing`.

The artist must already exist. Resolve names to IDs with `GET /api/station/:stationId/artists` and create any that are missing with `POST /api/station/:stationId/artists` before you start scheduling, so a typo in a roster does not become a half-built week.

## Building a sync that can run twice

Anything on a schedule (a weekly rebuild, a nightly CMS sync) will be re-run, retried, or run after a partial failure. Design for that from the start.

1. **Read the target range first.** One range query gives you everything already there.
2. **Match, don't blindly create.** Decide what makes an event the same event in the user's source data, usually title plus start time, or an external ID you keep in the description or title. Then:
   - present in both, unchanged → do nothing
   - present in both, changed → `PUT` to the event's `originalId` with `scope: 'this'`
   - in the source only → `POST`
   - in RadioCult only → ask before deleting; a show someone added by hand in the dashboard is not garbage to be cleaned up
3. **Dry run, then confirm.** For a direct request, show the user the list of creates, updates and deletes and get a go-ahead before writing. For a built sync, give it a `--dry-run` flag that prints the plan.
4. **Write sequentially**, not in parallel, and report progress as you go.
5. **Respect the rate limits.** Connect endpoints are rate limited per station with burst (per minute) and daily caps. Every response carries `X-RateLimit-Remaining-*` headers; slow down as they drop. On a `429`, wait the `Retry-After` seconds and back off exponentially on repeated failures.
6. **Summarise honestly at the end**: created, updated, deleted, skipped, and anything that failed with the reason.

Prefer a series over a loop. If the source data is "this show, every Thursday", create one recurring event rather than 52 standalone ones. It is one request instead of 52, it stays editable as a series in the dashboard, and extending it later is an update rather than another 52 writes.

## Troubleshooting

| Symptom                                       | Cause                                                                                   |
| --------------------------------------------- | --------------------------------------------------------------------------------------- |
| 402 on a write                                | Connect is not enabled for the station, or the station's plan is not active             |
| 401 or 403 on a write                         | The key is missing or unknown (401), or a publishable key was used for a write (403)    |
| 404 "Schedule not found" on a repeating show  | The URL used the occurrence's synthetic `id` instead of `originalId`                    |
| "Scope must be all for a non-recurring event" | `this` or `thisAndFollowing` sent for a standalone event; use `all`                     |
| Validation error naming an unknown field      | The body is strict; remove anything not in the reference                                |
| `rrule is not a supported recurrence pattern` | `COUNT`, `FREQ=YEARLY`, an unsupported interval, or mixed monthly by-parts              |
| `rrule does not align with startDateUtc`      | The monthly day-of-month or weekday-of-month does not match the start date              |
| Media validation failure                      | The playlist, track or relay ID does not exist on the station                           |
| Shows an hour out, seasonally                 | Local time converted to UTC with a fixed offset instead of the station's timezone rules |
| Duplicate shows after a re-run                | The sync creates without reading the range first                                        |

## Final checklist

- Secret key from an environment variable, never in client code or logs.
- The target range was read before anything was written.
- Local show times converted to UTC through the station's timezone, not the machine's.
- `duration` in minutes, 1 to 1440, and no `endDateUtc` in the request.
- Repeating shows created as one series with a supported `rrule`, not a loop of standalone events.
- Every write URL built from `originalId`, never from an occurrence's `id`, and `originalId` is what gets stored if you keep a reference.
- `scope` chosen deliberately on every update and delete, with `instanceStart` read from the schedule response.
- Destructive and bulk changes confirmed with the user first.
- Writes sequential, `429` handled with `Retry-After`, and a truthful summary at the end.
