- Primary JSON endpoints:
GET /api/eventsGET /api/tenders
- Supporting routes:
GET /api/image/:kind/:pageIdGET /api/sports/live,GET /api/now-showing(bar-TV “Now Showing” card)GET /api/setlist/*(analytics surface reads)POST /api/feedback+ the public/feedbackand/screenpagesGET /api/dmx/desired,GET /api/qsys/desired,POST /api/admin/dmx/desired(Pi relay)GET /api/screenings— closed movie screenings + soundtrack tracks for the/screenarchive (gated bySCREENINGS_ENABLED; see movie-nights)POST /api/admin/screenings/:id/revert— admin: un-tag a screening’s songs back to musicPOST /api/admin/sync-tender-qrsis retired (tender data is D1-native now)
GET /tender/:idis a compatibility redirect route used for deep linking into the tender directory UI- Both API responses are cached at the Cloudflare edge for 2 minutes
- Both endpoints fall back to placeholder or empty data when Notion credentials are not configured
Sources: worker.js (handleEventsApi, handleTendersApi, PLACEHOLDER block)
GET /api/events
Query params
| Param | Default | Notes |
|---|---|---|
window | 7 | Number of calendar days ahead to include in weekEvents. Capped at 30. The TV uses the default; /events/ requests ?window=14. Each distinct value is cached independently at the edge. |
Response shape
{
"todayEvents": [],
"weekEvents": [],
"announcements": [],
"pubHoursWeek": [],
"pubTender": {},
"slides": [],
"lastUpdated": "2026-03-26T02:20:00.000Z",
"source": "notion"
}source is "notion" when live data is returned and "placeholder" when the worker is serving fallback data.
lastUpdated is also the server timestamp the TV client uses to keep slideshow and featured-event state aligned across multiple screens.
Event object (todayEvents)
Today’s pub events, sorted by start time. This array powers the TV display’s Happening at the Pub panel.
Eligibility rules
An event is included in todayEvents only if all of the following are true:
Include on Pub TVis checkedLocationcontains"pub"(case-insensitive)Event Startlands on the worker’stodayISOwhen interpreted inAmerica/New_York
To make that local-date rule reliable, the worker intentionally queries a wider Notion window first and then narrows the result set in Eastern time.
Sources: worker.js (fetchFromNotion, isAtPub, isOnLocalDate)
{
"title": "Trivia Night",
"start": "8:30 PM",
"end": "10:00 PM",
"status": "Later",
"startIso": "2026-01-18T01:30:00.000Z",
"endIso": "2026-01-18T03:00:00.000Z",
"rcTag": "rc-direct"
}| Field | Type | Notes |
|---|---|---|
title | string | Event name from Notion |
start | string | Formatted start time |
end | string | Formatted end time; derived from Event End, Duration (hrs), or a 1-hour fallback |
status | string | Computed at request time: "Now", "Next", "Later", or "Done" |
startIso | string | UTC ISO 8601 start timestamp used for client-side re-evaluation |
endIso | string | UTC ISO 8601 end timestamp |
rcTag | string or null | "rc-direct", "rc-affiliate", or omitted |
Status freshness
statusis computed by the worker at request time. The TV display client re-evaluates status locally between API refreshes usingstartIso,endIso, and the server-aligned clock derived fromlastUpdated.
Sources: worker.js (transformPage, deriveStatus)
WeekEvent object (weekEvents)
Upcoming events for the next N calendar days (default 7, controlled by the ?window= param), sorted by start time. This array powers the TV display’s Coming Up at the Pub panel and the /events/ page.
- Uses the same pub-location and TV-include gates as
todayEvents - Includes rows with
Event Start >= tomorrowISOand< todayISO + (window + 1) days - Starts at calendar tomorrow, so same-day events are not duplicated there
- The TV client removes any row whose live status has already become
Done - The
/events/page requests?window=14to show a 14-day horizon; the TV uses the default 7-day window
Sources: worker.js (fetchFromNotion, transformWeekPage), public/tv/script.js (renderWeekEvents), public/events/index.html
{
"title": "Open Mic Night",
"date": "Wed, Jan 21",
"start": "9:00 PM",
"end": "11:00 PM",
"startIso": "2026-01-22T02:00:00.000Z",
"endIso": "2026-01-22T04:00:00.000Z",
"rcTag": null
}| Field | Type | Notes |
|---|---|---|
title | string | Event name |
date | string | Formatted date label |
start | string | Formatted start time |
end | string | Formatted end time |
startIso | string | UTC ISO 8601 start timestamp |
endIso | string | UTC ISO 8601 end timestamp |
rcTag | string or null | RC classification tag |
Announcement object (announcements)
Rows are included in announcements only when Include on Pub TV is checked in the Announcements database.
{
"id": "9cb8...",
"title": "Reminder",
"priority": "high",
"text": "The pub will close early this Friday at 10 PM.",
"imageUrl": "https://pub.ihnyc-rc.org/api/image/announcement/9cb8...",
"duration": 8
}| Field | Type | Notes |
|---|---|---|
id | string | Notion page id |
title | string | Announcement title from Name |
priority | string | Lowercased priority label; defaults to "normal" |
text | string | Announcement copy from Content or fallback Name |
imageUrl | string | Same-origin proxy URL for Picture, Image, or PNG file properties; may be empty |
duration | number or null | Optional seconds value used by the TV slideshow |
Sources: worker.js (fetchAnnouncementsFromNotion)
Slide object (slides)
The worker synthesizes slides from two existing sources:
- Every qualifying announcement becomes an
announcementslide - Every active pub tender with
Meet your Tenderchecked becomes atenderslide
The client uses this array to drive the left-panel slideshow.
{
"id": "announcement-1",
"type": "announcement",
"title": "Reminder",
"body": "The pub will close early this Friday at 10 PM.",
"imageUrl": "https://pub.ihnyc-rc.org/api/image/announcement/9cb8...",
"priority": "high",
"durationMs": 8000
}{
"id": "tender-1",
"type": "tender",
"title": "Meet your Tender",
"name": "Alex Smith",
"imageUrl": "https://pub.ihnyc-rc.org/api/image/tender/2ac2...",
"qrImageUrl": "https://pub.ihnyc-rc.org/api/image/tender-qr/2ac2...",
"qrUrl": "https://pub.ihnyc-rc.org/tenders/?tender=2ac2..."
}| Field | Type | Notes |
|---|---|---|
id | string | Stable slide identifier |
type | string | announcement or tender |
title | string | Slide title |
body | string | Announcement-only body copy |
priority | string | Announcement-only styling hint |
durationMs | number or null | Announcement-only slide duration in milliseconds |
name | string | Tender-only full name |
imageUrl | string | Same-origin proxy URL for either the announcement image or tender profile photo |
qrImageUrl | string | Tender-only pre-rendered QR image URL from the Notion QR file property |
qrUrl | string | Tender-only canonical in-site profile URL metadata; current TV code does not render this directly |
Important current behavior:
- Announcement slides may be text-only, image-only, or mixed image+text cards
- Tender slides do not include bio text in the worker payload
- Tender slides still include
qrUrlmetadata, but the TV currently renders onlyqrImageUrl - The TV and
/announcements/surfaces intentionally consume same-origin proxy image URLs rather than very long signed Notion file URLs
Sources: worker.js (buildSlides, fetchPubTenderFromNotion)
PubHoursDay object (pubHoursWeek)
Array of 7 items, one per day of the current week, indexed 0-6 from Sunday through Saturday.
{
"date": "2026-03-17",
"open": true,
"hours": "9:00 PM - 11:40 PM"
}| Field | Type | Notes |
|---|---|---|
date | string | ISO date (YYYY-MM-DD) in Eastern time |
open | boolean | false if pub is closed that day |
hours | string or null | Formatted operational timeframe; null if closed |
Sources: worker.js (fetchPubHoursWeek)
PubTender object (pubTender)
{
"name": "Alex & Jordan",
"hours": "9:00 PM - 11:40 PM",
"note": "On duty now",
"noteState": "active",
"pageUrl": "https://notion.so/...",
"activeTenders": []
}| Field | Type | Notes |
|---|---|---|
name | string | First name(s) of all on-duty tenders joined with " & " |
hours | string | Operational timeframe from the Shifts DB |
note | string | Human-readable shift state label |
noteState | string | Machine-readable state |
pageUrl | string | URL metadata from the first tender page with a URL property |
activeTenders | array | Profile-slide data for tenders with Meet your Tender checked |
noteState values
| Value | Meaning |
|---|---|
upcoming | Shift starts later today |
setup | Within full shift window, before operational hours begin |
active | Within operational hours |
last-call | Within lastCallMinutes minutes of operational end |
closed | After operational hours but still within the full shift |
inactive | No active shift found for today |
Sources: worker.js (fetchPubTenderFromNotion, deriveShiftNote)
ActiveTender object (pubTender.activeTenders)
Used internally by the worker and client to build tender-profile slides.
| Field | Type | Notes |
|---|---|---|
id | string | Normalized tender id |
fullName | string | Full name from the tender’s Notion page title |
showProfile | boolean | Whether the tender should appear in slideshow profile slides |
imageUrl | string | Same-origin proxy URL for the Picture file |
qrImageUrl | string | Same-origin proxy URL for the QR file |
pageUrl | string | URL metadata from the tender page; not surfaced on TV slides today |
GET /api/tenders
Response shape
{
"tenders": [],
"lastUpdated": "2026-03-26T02:20:00.000Z",
"source": "notion"
}source is "notion" when live data is returned and "placeholder" when the worker is serving fallback data.
Tender directory selection rules
- The worker resolves the Pub Tenders database id directly from
NOTION_PUB_TENDERS_DATABASE_IDwhen present - Otherwise it walks recent shift → assignment → tender relations until it finds the tender database parent id
- Only tender pages whose
Activecheckbox is true are returned - Results are sorted alphabetically by
firstName, thenfullName
Sources: worker.js (fetchTenderDirectoryFromNotion, resolveTenderDirectoryDatabaseId)
TenderDirectoryEntry object
{
"id": "2ac2fadc633680eca9ead2958f9bc9aa",
"notionPageId": "2ac2fadc-6336-80ec-a9ea-d2958f9bc9aa",
"firstName": "Alex",
"fullName": "Alex Smith",
"zelle": "alex@example.com",
"venmo": "https://venmo.com/code?...",
"bio": "Resident bartender and trivia host.",
"imageUrl": "https://...",
"pageUrl": "https://...",
"profileUrl": "/tenders/?tender=2ac2fadc633680eca9ead2958f9bc9aa"
}| Field | Type | Notes |
|---|---|---|
id | string | Normalized Notion id used by the frontend and redirect route |
notionPageId | string | Original Notion page id |
firstName | string | Derived from the first token of fullName |
fullName | string | Display name from the tender page |
zelle | string | Raw Zelle value from Notion; frontend treats email and phone values specially |
venmo | string | Raw Venmo value from Notion; frontend accepts either a full Venmo URL or an @handle-style value |
bio | string | Directory modal biography text |
imageUrl | string | Tender profile image |
pageUrl | string | First resolved URL property from the tender page |
profileUrl | string | Canonical in-site deep link for opening this tender profile |
The /tenders/ page uses this endpoint once on load, renders one card per active tender, and opens the modal indicated by the query string or deep-link route.
Sources: worker.js (transformTenderDirectoryPage), public/tenders/app.js
Redirect Route: /tender/:id
This route is not a JSON endpoint. The Worker normalizes the supplied Notion id and returns a 302 redirect to:
/tenders/?tender=<normalized-id>That lets QR codes, copied links, and internal links point at the stable query-based route while the actual UI continues to live inside the modal-backed /tenders/ page. /tender/:id remains as a compatibility redirect for older links.
Sources: worker.js (fetch handler), public/tenders/app.js
GET /api/image/:kind/:pageId
Short same-origin image route used by the TV display and announcements page.
Supported kind values:
announcementtendertender-qr
The worker resolves the Notion page id, looks up the corresponding file property, fetches the upstream image, and returns it with a 2-minute cache header. This exists mainly to keep browser-facing image URLs short and same-origin.
Sources: worker.js (handleImageProxyApi, resolveNotionImageUrl)
Pub admin authorization
Browser requests use a verified Cloudflare Access identity and the required permission from pub_admin_roles. The Worker checks page and API routes against the canonical permission registry; a valid Access session without the required Pub role receives 403.
The separate root path accepts Authorization: Bearer <PUB_ADMIN_TOKEN> or X-Admin-Token: <PUB_ADMIN_TOKEN> and grants *. It is for automation and recovery. Admin HTML does not embed that token.
See Pub admin identity and permissions for scopes and bundles.
POST /api/sync-tender-qrsretiredThe standalone tender-QR sync endpoint has been removed — tender data is D1-native now, so the Short.io / Notion
QRround-trip no longer runs. Older links that hit it should be repointed.
Sources: worker/lib/admin-auth.js, worker/lib/admin-permissions.js
POST /api/now-playing/ingest
Ingest endpoint for the Pi capture service. Accepts three payload shapes, distinguished by which boolean flag is present.
Auth
Optional NOW_PLAYING_INGEST_TOKEN shared secret. When set as a Wrangler secret, the Worker requires Authorization: Bearer <token> and returns 401 otherwise. When unset, the Worker accepts unauthenticated POSTs and logs a warning (intended for local dev).
Payload: identified track
{
"artist": "it's murph & YDG",
"title": "Down Low (feat. Sorana) [YDG Remix]",
"album": "Down Low",
"album_art_url": "https://...",
"isrc": "QM6P42639553",
"source": "program",
"started_at": "2026-05-19T18:48:54.751Z",
"confidence": 0.92
}artist and title are required. Others are optional. source is one of program / wallstereo / wallxlr / unknown; unknown / unrecognized values are stored as null. started_at defaults to the Worker’s current time if absent.
Dedupe: if the most recent non-silent setlist row has the same (artist, title) AND started_at within NOW_PLAYING_DEDUPE_WINDOW_SECONDS = 30 s, the Worker returns {ok: true, deduped: true, id: <existing>} without inserting.
Payload: silence sentinel
{ "silent": true }When silent === true, the Worker skips artist/title validation, inserts a row with silent=1 and empty strings for artist/title, and returns {ok: true, silent: true, id: <new>}. SSE clients see data: null on the next track event (TV footer hides, /setlist/ shows the “Quiet right now” card). Filtered out of the /setlist/ history.
Payload: heartbeat
{ "heartbeat": true }Refreshes the single-row heartbeats table (INSERT ... ON CONFLICT(id) DO UPDATE). No setlist row created. Returns {ok: true, heartbeat: true, last_seen: "<ISO>"}. The Pi posts these every 30 s independent of audio state.
Sources: worker.js (handleNowPlayingIngest)
GET /api/now-playing/current
Server-Sent Events (SSE) stream. The long-lived connection carries the current track, Pi/Q-SYS/quiet state, right-display and lyrics controls, and watchdog pings. The Worker closes the connection after 5 minutes (NOW_PLAYING_SSE_MAX_CONNECTION_MS) so clients reconnect cleanly.
Events
| Event | Payload | When emitted |
|---|---|---|
now_playing_display | {barRight: boolean} | On connect and when the right-TV Now Playing display setting changes. |
track | null OR a formatted setlist row (see below) | On connect (initial snapshot from latest D1 row), and when a new row’s id differs from the last sent. Emits null when the latest row is a silence sentinel (silent=1) or has passed NOW_PLAYING_STALE_MS = 10 min. |
status | {state, qsysDown, quietActive} | On connect and when any value changes. state is online or offline; the other fields are booleans. Offline = no Pi heartbeat within NOW_PLAYING_OFFLINE_MS = 90 s. |
lyrics_toggle | {enabled: boolean, projector: boolean, barRight: boolean} | On connect and when either surface’s lyrics setting changes. enabled is true when at least one surface is enabled. |
lyrics | {trackId, available, lrc?, source?} | When lyrics are enabled and track or availability state changes. A null trackId with available: false clears the current lyrics. |
offset | {trackId, offsetMs, capturedAt?, confidence?, ageMs?, available?} | When lyrics are enabled and the stored synchronization offset changes. A null track and offset clear the current value. |
ping | {ts: <epoch ms>} | On connect, then every 30 s. Lets clients drive a watchdog for zombie connections. |
track row shape
{
"id": 213,
"startedAt": "2026-05-19T18:48:54.751Z",
"artist": "it's murph & YDG",
"title": "Down Low (feat. Sorana) [YDG Remix]",
"album": null,
"albumArtUrl": "https://...",
"isrc": "QM6P42639553",
"source": "program",
"confidence": null,
"silent": false
}Comment lines (: connected, : keepalive <ts>) are also emitted but aren’t named SSE events; they keep proxies from closing the connection.
Sources: worker.js (handleNowPlayingCurrent)
GET /api/now-playing/setlist
Cursor-paginated history of identified tracks. The /setlist/ page uses it for initial load, bounded-scope backfill, and All time infinite scroll. Silence sentinels and non-music rows do not appear in public history.
Query params
| Param | Default | Notes |
|---|---|---|
limit | 100 | Max 500. |
before | none | ISO timestamp cursor. Returns rows with started_at < before. |
Response
{
"items": [ { "id": 213, "startedAt": "...", ... }, ... ],
"nextBefore": "2026-05-19T18:00:00.000Z",
"limit": 100
}nextBefore is null when the page is not full (no more pages). When non-null, clients pass it back as before for the next page.
Sources: worker.js (handleNowPlayingSetlist, formatSetlistRow)
WebSocket /api/now-playing/controls
Server-push channel for the right TV’s Now Playing visibility and both lyrics toggles. The client must request a WebSocket upgrade. The server sends the current state immediately, then sends the same shape after an administrator changes a setting:
{
"type": "now_playing_controls",
"barRightDisplay": true,
"projectorLyrics": false,
"barRightLyrics": false
}The channel is public and read-only. It returns 426 without a WebSocket upgrade and 503 when the TV_PAIRING Durable Object binding is unavailable.
Sources: worker.js (handleNowPlayingControlsSocket, TvPairingCoordinator.openNowPlayingControlsSocket, normalizeNowPlayingControls)
GET /api/now-playing/snapshot
One-shot JSON form of the current music track for pollers that do not consume SSE. The Pi DMX controller uses it for song-reactive lighting.
{
"track": null
}track has the same base shape as the SSE row, with a best-effort genre field from track_enrichment. It is null when no row exists or the latest row is silent, stale, or classified as video. The endpoint is public and uses Cache-Control: no-store.
Sources: worker.js (handleNowPlayingSnapshot, formatSetlistRow)
POST /api/now-playing/offset
Stores the Pi’s current lyrics synchronization offset in the LYRICS KV namespace for 60 seconds. It uses the same bearer-token rule as /api/now-playing/ingest.
| Field | Required | Rule |
|---|---|---|
trackId | yes | Positive number |
offsetMs | yes | Non-negative number; rounded before storage |
confidence | no | Number clamped to 0–1; defaults to 0 |
source | no | At most 32 characters; defaults to chromaprint |
The Worker adds capturedAt using its own clock and returns {ok: true}. The request body is limited to 1 KB.
Sources: worker.js (handleNowPlayingOffset, LYRICS_OFFSET_TTL_SECONDS)
GET /api/sports/live
Cached ESPN live-sports scoreboard for the bar-TV “Now Showing” card. Keyless ESPN feed; the schedule cron refreshes the sports:live KV blob (30-min TTL), and this read cold-fetches once if KV is empty. Gated by SPORTS_ENABLED (returns 404 {enabled:false, games:[]} when off). Served Cache-Control: public, max-age=30.
{
"enabled": true,
"fetched_at": 1718900000000,
"live": [],
"games": []
}live is the subset of games with state === "in" (in-progress).
Sources: worker.js (handleSportsLive, fetchSportsScoreboard)
GET /api/now-showing
Resolved “Now Showing” card the bar TV renders — fuses the Pi’s audio content verdict (heartbeats.content_status) with the ESPN schedule, with an optional operator override layered on top. Gated by NOW_SHOWING_ENABLED (returns 404 {enabled:false, showing:false} when off). Served no-store.
Sources: worker.js (handleNowShowing, computeNowShowing)
/api/admin/now-showing
Operator “what’s on the big screen” picker behind the /admin/now-showing page. Admin-gated.
GET→ current override, the resolved card, and the pickable live-game listPOST {mode, label?, gameId?}→ set the override (auto/ movie / specific game / generic / off); audited; persisted to KV
Sources: worker.js (handleAdminNowShowing)
DMX lighting relay
Declarative desired/current mirror of the DMX par-can rig (single-row pub_dmx_state on SETLIST_DB). The worker holds the desired intent; the Pi is the source of truth and reconciles its rig within ~5s, then reports back via the heartbeat (which clears the desired row on reach).
POST /api/admin/dmx/desired
Admin-gated (PUB_ADMIN_TOKEN). Body {action, name?, params?} where action is one of the allowed DMX_ACTIONS (scene / program / music mode / blackout / etc.); name is optional (≤64 chars); params is an optional plain object passed through to the Pi. The serialized intent is capped at 2 KB. Returns {ok:true, desired}.
GET /api/dmx/desired
The Pi’s fast poll for the pending intent. Auth: Authorization: Bearer <NOW_PLAYING_INGEST_TOKEN> (the same Bearer token as /api/now-playing/ingest and /api/qsys/desired; unauthenticated only when the token is unset). Returns {ok:true, desired, desiredSetAt} (or null desired when none pending / no DB).
Sources: worker.js (handleAdminDmxDesired, handleDmxDesired)
GET /api/qsys/desired
The Pi’s fast poll for a pending Q-SYS “sound stage” mode change (single-row pub_qsys_state on SETLIST_DB). Same Bearer token as /api/now-playing/ingest. Returns the desired mode (or null when none pending / no DB). The Core is the source of truth; this is the pending request the Pi applies over QRC.
Sources: worker.js (handleQsysDesired)
GET /api/setlist/:surface
Read-only JSON for the /setlist analytics surfaces. The serve path only reads materialized rollups (setlist_rollups on SETLIST_DB) — it never computes inline; a scheduled() cron dispatcher writes one due surface per tick. A surface returns 503 while it is still computing (no rollup yet).
Surfaces: top-songs, vibe-compass, echo, tonight, wrapped, persona, stories. Each page also has an HTML route (/setlist/:surface) and most have an OG image (/og/setlist/:surface.png). Several are additionally gated by per-surface env flags (e.g. SETLIST_LEAD_SURFACE_ENABLED, SETLIST_VIBE_COMPASS_ENABLED).
Related reads: POST /api/setlist/share-click (anonymous share-click metric), and the song-request endpoints (POST /api/setlist/request, GET /api/setlist/requests, GET /api/setlist/track-suggest, POST /api/setlist/request/:id/vote) gated by SETLIST_REQUESTS_ENABLED.
Sources: worker.js (handleSetlistSurfaceRead, runSetlistDispatcher)
/screen + /api/screenings
Public “Movie Nights” archive. /screen is the server-rendered dark archive page (sibling of /setlist); /og/screen.png is its Open Graph share image; GET /api/screenings returns closed, non-shadow screenings plus their tracks in one query. Gated by SCREENINGS_ENABLED / NOW_SHOWING_ENABLED.
Sources: worker.js (handleScreenPage, handleScreeningsApi, handleScreenOg)
POST /api/feedback + /feedback
Patron “tell us what you think” suggestion box. /feedback (and /feedback/) is the public form page; POST /api/feedback accepts the one-way message + optional 1—5 rating / name / contact and writes the pub_feedback table. Gated by FEEDBACK_ENABLED (ships OFF). Per-browser-token rate limit (6/hour). Operator triage is admin-side at /admin/feedback (/api/admin/feedback). All patron text is rendered admin-side via DOM textContent only (XSS guard).
Sources: worker.js (handleFeedbackSubmit, handleFeedbackPage, handleAdminFeedbackApi)
Integration Notes
Partial configuration
When
NOTION_SHIFTS_DATABASE_ID,NOTION_ANNOUNCEMENTS_DATABASE_ID, orNOTION_PUB_TENDERS_DATABASE_IDare missing, the response keys still exist but may contain empty arrays or fallback values. Clients should handle this gracefully.
SETLIST_DB binding
The Now-Playing endpoints require the
SETLIST_DBD1 binding to be wired inwrangler.toml. Without it, all three endpoints return503 SETLIST_DB binding not configured. See Storage model.