# WorshipKit Billboard API

A guide to the Billboard REST API — for church admins who want to
automate their signage from a script, for the AI assistants helping
them, and for the WorshipKit team.

**Public URL:** https://worshipkit.com/billboard-api
**Raw markdown at:** https://worshipkit.com/billboard-api.md

Everything on this page is scoped to the authenticated user's
Organization — you can't see or change another org's playlists,
devices, or media.

---

## What Billboard is

Billboard is WorshipKit's digital-signage system. A church admin puts
media (images, videos, live streams, Flickr album refs, countdowns)
into **playlists**, assigns those playlists to **devices** — a Roku,
an Android TV, a webOS TV, a browser tab on a hallway display — and
the devices loop the playlist until told otherwise.

Core objects:

- **Media item** (`BillboardMediaItem`) — an image, video, or embed.
  Optionally scoped to a date window (`start_date` .. `end_date`).
- **Playlist** (`BillboardPlaylist`) — an ordered list of media items,
  played on a loop.
- **Device** (`BillboardOutput`) — a physical / virtual display that
  plays a playlist. Devices are paired to the org via a short code.
- **Device group** (`BillboardDeviceGroup`) — many devices playing
  the same playlist; changing the group's playlist changes all its
  devices at once.

---

## Authentication

Every request needs a Bearer token in the `Authorization` header. Two
token types work:

- **Personal Access Token (PAT)** — created by a signed-in user in
  the browser at [/settings/api-tokens](https://worshipkit.com/settings/api-tokens)
  (accessible from the user menu → **Your Profile** → *"Manage API
  tokens →"*). The raw token is shown ONCE at creation time; copy
  it into your agent's config immediately, and revoke old tokens
  from the same page. Scripted alternative: `POST /api_tokens
  { "name": "…" }` returns the same JSON envelope, but the browser
  flow is the recommended path for humans.
  Scope: everything the user can do.
- **Doorkeeper OAuth token** — issued by the standard OAuth2 flow at
  `/oauth/authorize`. Scoped.

```
Authorization: Bearer wk_pat_abc123…
Content-Type:  application/json
```

### Billboard access by role

A valid token is not enough — the token's user must also have access to
the Billboard app in their organization. Every `/api/v2/billboard/*`
endpoint enforces this and returns `403` with
`{ "error": "Insufficient permissions", "hint": "Ask your Admin to grant
you access to Billboard." }` when it fails.

- **Owner** and **Admin** — always have Billboard access.
- **Editor** — only if an Owner/Admin granted them the Billboard feature
  (and the org is subscribed to it).
- **User** — no Billboard access; every Billboard call returns `403`.

If you get a `403` on an endpoint whose scope looks correct, the user's
role or feature grant is the cause — ask them to have an Admin grant
Billboard access.

All examples below assume the base URL `https://worshipkit.com` and
that header is set. Anything you `POST` / `PUT` uses a JSON body.

---

## Playlists

### Create a playlist

```
POST /api/v2/playlists
```

```json
{
  "playlist": {
    "title": "Sunday morning welcome",
    "is_active": true,
    "seconds_per_slide": 10
  }
}
```

Response: the created playlist as JSON. Save the `id`.

### List / read / update / delete

```
GET    /api/v2/playlists
GET    /api/v2/playlists/:id
PUT    /api/v2/playlists/:id
DELETE /api/v2/playlists/:id
```

Updatable fields (all optional): `title`, `is_active`,
`billboard_flickr_album_id`, `service_type_id`,
`countdown_leading_minutes`, `seconds_per_slide`,
`series_art_enabled`, `series_art_source`,
`series_art_service_type_id`, `series_art_uploaded_url`,
`series_art_uploaded_filename`.

### Series art (per-playlist, #482)

Each playlist can render a "series art" tile as the first preview
in its rotation. Two sources are supported:

- **Planning Center** — set `series_art_source` to `"pco"` and
  `series_art_service_type_id` to one of the caller org's
  `ServiceType`s. The playlist tile shows the artwork attached to
  the nearest current/upcoming plan for that service type, refreshed
  by the org's normal PCO sync (no per-request PCO calls).
- **Manual upload** — set `series_art_source` to `"upload"` and
  `series_art_uploaded_url` (S3 URL from the standard
  `/billboard/signed_urls/v2` flow) + `series_art_uploaded_filename`
  (for display).

The tile only renders when both `series_art_enabled` is `true` AND
the source resolves to an image URL. Send `""` on any of the four
`series_art_*` string fields to clear it (Disconnect / Remove flows).

Cross-org protection: passing a `series_art_service_type_id` that
belongs to another org is rejected with 422 on both `POST /playlists`
and `PATCH /playlists/:id`.

The playlist JSON also exposes these read-only projections:

- `per_playlist_series_art_active?` — true when the tile will render
- `effective_per_playlist_series_art_url` — the resolved image URL
- `effective_per_playlist_series_art_title` — filename (upload) or
  cached PCO series title, falling back to `"Series Art"`

```
GET /api/v2/playlists/series_art_service_types
```

Returns the caller org's `ServiceType`s as `[{ id, name }, …]` for
the SPA's Connect picker. Org-scoped.

### Add a media item to a playlist

```
POST /api/v2/playlists/:playlist_id/add_media_item
```

```json
{ "media_item_id": 42 }
```

The item lands at the end of the playlist. Response is the full
playlist including `billboard_playlist_media_items`.

### Remove a media item

```
DELETE /api/v2/playlists/:playlist_id/remove_media_item
```

```json
{ "item_id": 17 }
```

`item_id` is the **playlist-item id** (from
`billboard_playlist_media_items`), NOT the underlying `media_item_id`.
When a playlist has an item added multiple times, each has its own
playlist-item id.

### Reorder items

```
PUT /api/v2/playlists/:playlist_id/reorder_items
```

```json
{ "items": [17, 42, 88, 12] }
```

Array of playlist-item ids in the order they should play.

### Publish (push updates to devices)

```
POST /api/v2/playlists/:playlist_id/publish
```

Enqueues an async `BillboardPlaylistExportJob` (204 → 202 with a
poll id). Devices refresh via Pusher when it lands.

### Per-item display mode

```
PUT /api/v2/playlists/:playlist_id/items/:item_id/display_mode
```

```json
{ "display_mode": "scale_to_fit" }
```

Valid modes are documented in `docs/billboard_image_display_modes.md`.

---

## Media items

### Upload / register

```
POST /api/v2/media/add_upload
```

```json
{
  "upload_details": {
    "title": "Autumn sunrise",
    "credit": "Photo by Sam L.",
    "url": "https://s3.amazonaws.com/org-42/autumn.jpg"
  }
}
```

The file must already exist at `url` (usually an S3 presigned upload
URL — see `POST /api/v2/signed_urls` for how the WK web UI obtains
them). A background job generates thumbnails afterwards.

### List / read / update / delete

```
GET    /api/v2/media
GET    /api/v2/media/:id
PUT    /api/v2/media/:id
DELETE /api/v2/media/:id
```

Updatable fields: `title`, `credit`, `start_date`, `end_date`, `url`.

### Scheduling — start / end dates

`start_date` and `end_date` on a media item are **global** —
they control when the item appears on any playlist it's part of.
The Billboard player filters items out of the running loop whenever
the current time is outside a scheduled item's window.

Both fields accept ISO-8601 timestamps in UTC (`"2026-12-24T18:00:00Z"`)
or a bare date (`"2026-12-24"`, interpreted as `00:00Z`). Omit or
`null` a field to remove that bound.

Example — a Christmas Eve slide that only appears the last week of
December:

```json
{
  "billboard_media_item": {
    "start_date": "2026-12-19T00:00:00Z",
    "end_date":   "2026-12-26T00:00:00Z"
  }
}
```

PUT to `/api/v2/media/:id`.

> **Note:** date windows are attached to the media item itself, not
> to a `playlist_media_item` junction. If you want the same photo to
> run in two different windows on two different playlists, register
> it as two media items with different date windows.

---

## Devices

Devices in the schema are `BillboardOutput` rows — one row per
physical display.

### List / read / update / delete

```
GET    /api/v2/devices
GET    /api/v2/devices/:id
POST   /api/v2/devices
PUT    /api/v2/devices/:id
DELETE /api/v2/devices/:id
```

Updatable fields: `title`, `notes`, `campus_id`, `pairing_code`,
`billboard_playlist_id`, `billboard_device_group_id`.

### Assign a device to a playlist

Two ways depending on scale:

**Directly on the device** — the device plays this exact playlist:

```
PUT /api/v2/devices/:id
```

```json
{ "device": { "billboard_playlist_id": 123 } }
```

**Via a device group** — many devices share one playlist:

```
PUT /api/v2/devices/:id
{ "device": { "billboard_device_group_id": 7 } }
```

Then set the group's playlist:

```
PUT /api/v2/device_groups/7
{ "device_group": { "billboard_playlist_id": 123 } }
```

Now every device in group 7 plays playlist 123.

### Pair a new device

The device shows a short code on-screen. A signed-in user:

```
POST /api/v2/devices/get_code    → { code: "ABCD" }
POST /api/v2/devices/validate_code
{ "code": "ABCD" }
```

`validate_code` claims the device for the current org and returns the
`BillboardOutput` row for further configuration.

---

## Device groups

```
GET    /api/v2/device_groups
GET    /api/v2/device_groups/:id
POST   /api/v2/device_groups
PUT    /api/v2/device_groups/:id
DELETE /api/v2/device_groups/:id
POST   /api/v2/device_groups/:id/add_device
DELETE /api/v2/device_groups/:id/remove_device
```

Updatable fields: `name`, `billboard_playlist_id`.

### Add / remove devices

```
POST /api/v2/device_groups/:group_id/add_device
{ "device_id": 42 }

DELETE /api/v2/device_groups/:group_id/remove_device
{ "device_id": 42 }
```

Devices removed from a group lose the group's playlist unless you
also set `billboard_playlist_id` directly on the device.

---

## Common workflows

### 1. Create a playlist, add two items, publish

```
POST /api/v2/playlists
  { "playlist": { "title": "Foyer loop", "seconds_per_slide": 8 } }
→ { id: 501, ... }

POST /api/v2/playlists/501/add_media_item  { "media_item_id": 88 }
POST /api/v2/playlists/501/add_media_item  { "media_item_id": 91 }

POST /api/v2/playlists/501/publish
```

### 2. Assign the lobby TV to that playlist

```
GET  /api/v2/devices                       → find the "Lobby TV" row
PUT  /api/v2/devices/12
  { "device": { "billboard_playlist_id": 501 } }
```

### 3. Group four hallway TVs and swap their playlist for the summer

```
POST /api/v2/device_groups
  { "device_group": { "name": "Hallways", "billboard_playlist_id": 501 } }
→ { id: 7, ... }

POST /api/v2/device_groups/7/add_device  { "device_id": 12 }
POST /api/v2/device_groups/7/add_device  { "device_id": 13 }
POST /api/v2/device_groups/7/add_device  { "device_id": 14 }
POST /api/v2/device_groups/7/add_device  { "device_id": 15 }

# Later:
PUT /api/v2/device_groups/7
  { "device_group": { "billboard_playlist_id": 604 } }
```

### 4. Add a Christmas-only slide

```
POST /api/v2/media/add_upload
  { "upload_details": { "title": "Christmas eve",
                        "credit": "Photo: Kim P.",
                        "url": "https://s3.example/christmas.jpg" } }
→ { id: 130, ... }

PUT /api/v2/media/130
  { "billboard_media_item": {
      "start_date": "2026-12-19T00:00:00Z",
      "end_date":   "2026-12-26T00:00:00Z"
  } }

POST /api/v2/playlists/501/add_media_item  { "media_item_id": 130 }
POST /api/v2/playlists/501/publish
```

Between Dec 19 and Dec 26 the slide plays; outside that window the
device silently skips it.

### 5. Remove an item from a playlist without deleting the media

```
GET    /api/v2/playlists/501                # find the target playlist-item id
DELETE /api/v2/playlists/501/remove_media_item  { "item_id": 4402 }
POST   /api/v2/playlists/501/publish
```

`4402` is the `BillboardPlaylistMediaItem.id`, not the underlying
media id — same media item can be present multiple times in a
playlist and each has its own row.

---

## Errors

Standard REST responses:

- `200` — success (returns the affected object as JSON)
- `201` — created (POST that made something new)
- `202` — accepted (async job started; poll for status)
- `204` — success, no body
- `401` — no token, expired token, or missing `Authorization` header
- `403` — token lacks the required scope, **or** the token's user does
  not have access to Billboard (see below)
- `404` — object not found or not in your org
- `422` — validation failed; body has `{ "error": "message" }`

---

## For AI agents

If a WorshipKit user asks you to configure Billboard for them:

1. Ask the user for a PAT (or link them to
   `https://worshipkit.com/settings/api-tokens` to create one). Do
   not ask for their WorshipKit password.
2. Use `GET /api/v2/devices` and `GET /api/v2/playlists` first so
   you're proposing edits against IDs the org actually has.
3. When adding many items to a playlist, add them first, then
   `POST /publish` once at the end — publish enqueues a job that
   pushes to devices, so batching saves updates.
4. Media-item `start_date` / `end_date` is global. If the user says
   "show this only on our Sanctuary TV during Advent," they mean
   `start_date` / `end_date` on the media item — there is no
   per-playlist scheduling; put date-windowed items in a dedicated
   seasonal playlist if you need per-device isolation.
5. Devices identify themselves in the payload by their `id`. If a
   user names a device ("Lobby TV"), fetch `GET /api/v2/devices` and
   match against `title` — don't guess IDs.

---

## Where this lives

- **Source of truth:** `docs/billboard_api.md` in the WorshipKit web
  repo.
- **Served publicly at:** `/billboard-api` (HTML) and
  `/billboard-api.md` (raw markdown).
- **Discovered by AI agents via:** `/llms.txt` at the site root.
- **Kept up to date by:** the "Billboard API docs stay current"
  contributor rule in `CLAUDE.md`.

If you add, rename, or remove a Billboard endpoint, field, or wire
shape in `app/controllers/api/v2/billboard/*` or the underlying
schema, update this file in the same commit.
