• Primary JSON endpoints:
    • GET /api/events
    • GET /api/tenders
  • Supporting routes:
    • GET /api/image/:kind/:pageId
    • GET /api/sports/live, GET /api/now-showing (bar-TV “Now Showing” card)
    • GET /api/setlist/* (analytics surface reads)
    • POST /api/feedback + the public /feedback and /screen pages
    • GET /api/dmx/desired, GET /api/qsys/desired, POST /api/admin/dmx/desired (Pi relay)
    • GET /api/screenings — closed movie screenings + soundtrack tracks for the /screen archive (gated by SCREENINGS_ENABLED; see movie-nights)
    • POST /api/admin/screenings/:id/revert — admin: un-tag a screening’s songs back to music
    • POST /api/admin/sync-tender-qrs is retired (tender data is D1-native now)
  • GET /tender/:id is 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

ParamDefaultNotes
window7Number 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 TV is checked
  • Location contains "pub" (case-insensitive)
  • Event Start lands on the worker’s todayISO when interpreted in America/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"
}
FieldTypeNotes
titlestringEvent name from Notion
startstringFormatted start time
endstringFormatted end time; derived from Event End, Duration (hrs), or a 1-hour fallback
statusstringComputed at request time: "Now", "Next", "Later", or "Done"
startIsostringUTC ISO 8601 start timestamp used for client-side re-evaluation
endIsostringUTC ISO 8601 end timestamp
rcTagstring or null"rc-direct", "rc-affiliate", or omitted

Status freshness

status is computed by the worker at request time. The TV display client re-evaluates status locally between API refreshes using startIso, endIso, and the server-aligned clock derived from lastUpdated.

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 >= tomorrowISO and < 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=14 to 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
}
FieldTypeNotes
titlestringEvent name
datestringFormatted date label
startstringFormatted start time
endstringFormatted end time
startIsostringUTC ISO 8601 start timestamp
endIsostringUTC ISO 8601 end timestamp
rcTagstring or nullRC 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
}
FieldTypeNotes
idstringNotion page id
titlestringAnnouncement title from Name
prioritystringLowercased priority label; defaults to "normal"
textstringAnnouncement copy from Content or fallback Name
imageUrlstringSame-origin proxy URL for Picture, Image, or PNG file properties; may be empty
durationnumber or nullOptional 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 announcement slide
  • Every active pub tender with Meet your Tender checked becomes a tender slide

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..."
}
FieldTypeNotes
idstringStable slide identifier
typestringannouncement or tender
titlestringSlide title
bodystringAnnouncement-only body copy
prioritystringAnnouncement-only styling hint
durationMsnumber or nullAnnouncement-only slide duration in milliseconds
namestringTender-only full name
imageUrlstringSame-origin proxy URL for either the announcement image or tender profile photo
qrImageUrlstringTender-only pre-rendered QR image URL from the Notion QR file property
qrUrlstringTender-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 qrUrl metadata, but the TV currently renders only qrImageUrl
  • 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"
}
FieldTypeNotes
datestringISO date (YYYY-MM-DD) in Eastern time
openbooleanfalse if pub is closed that day
hoursstring or nullFormatted 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": []
}
FieldTypeNotes
namestringFirst name(s) of all on-duty tenders joined with " & "
hoursstringOperational timeframe from the Shifts DB
notestringHuman-readable shift state label
noteStatestringMachine-readable state
pageUrlstringURL metadata from the first tender page with a URL property
activeTendersarrayProfile-slide data for tenders with Meet your Tender checked

noteState values

ValueMeaning
upcomingShift starts later today
setupWithin full shift window, before operational hours begin
activeWithin operational hours
last-callWithin lastCallMinutes minutes of operational end
closedAfter operational hours but still within the full shift
inactiveNo 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.

FieldTypeNotes
idstringNormalized tender id
fullNamestringFull name from the tender’s Notion page title
showProfilebooleanWhether the tender should appear in slideshow profile slides
imageUrlstringSame-origin proxy URL for the Picture file
qrImageUrlstringSame-origin proxy URL for the QR file
pageUrlstringURL 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_ID when present
  • Otherwise it walks recent shift assignment tender relations until it finds the tender database parent id
  • Only tender pages whose Active checkbox is true are returned
  • Results are sorted alphabetically by firstName, then fullName

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"
}
FieldTypeNotes
idstringNormalized Notion id used by the frontend and redirect route
notionPageIdstringOriginal Notion page id
firstNamestringDerived from the first token of fullName
fullNamestringDisplay name from the tender page
zellestringRaw Zelle value from Notion; frontend treats email and phone values specially
venmostringRaw Venmo value from Notion; frontend accepts either a full Venmo URL or an @handle-style value
biostringDirectory modal biography text
imageUrlstringTender profile image
pageUrlstringFirst resolved URL property from the tender page
profileUrlstringCanonical 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:

  • announcement
  • tender
  • tender-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-qrs retired

The standalone tender-QR sync endpoint has been removed — tender data is D1-native now, so the Short.io / Notion QR round-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

EventPayloadWhen emitted
now_playing_display{barRight: boolean}On connect and when the right-TV Now Playing display setting changes.
tracknull 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

ParamDefaultNotes
limit100Max 500.
beforenoneISO 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.

FieldRequiredRule
trackIdyesPositive number
offsetMsyesNon-negative number; rounded before storage
confidencenoNumber clamped to 01; defaults to 0
sourcenoAt 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 list
  • POST {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, or NOTION_PUB_TENDERS_DATABASE_ID are 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_DB D1 binding to be wired in wrangler.toml. Without it, all three endpoints return 503 SETLIST_DB binding not configured. See Storage model.