Read-only JSON API served by the `channel-surf-tv` Cloudflare Worker in front of the D1 database (`channel-surf-tv-spots`). All endpoints are `GET`; any other method returns `405 METHOD_NOT_ALLOWED`. Unknown `/api/*` paths return `404 NOT_FOUND`. Errors share the envelope `{error, code}`. Successful responses are cacheable (`Cache-Control: public, max-age=300, stale-while-revalidate=600`) and CORS-enabled for allow-listed origins. This spec is the source of truth for the API surface: `npm run api:gen` converts it to `src/worker/openapi.json` (served live at `/api/openapi.json`) and renders `docs/api/API.md`. `npm run health` diffs worker routes against these paths and flags drift.
GET /api/spots — Paginated, filterable surf spot listGET /api/spots/map — Lightweight spot markers for the Leaflet mapGET /api/spots/meta — Facet counts for the spot filter sidebarGET /api/spots/{slug} — Spot detail with images, tags, and linked surfersGET /api/surfers — Paginated, filterable surfer listGET /api/surfers/meta — Facet counts for the surfer filter sidebarGET /api/surfers/{slug} — Surfer detail with images and linked spotsGET /api/quotes — Quotes wall collectionGET /api/quotes/random — Quote of the DayGET /api/clips — Video clip inventoryGET /api/events — Surf event calendar with derived statusGET /api/events/live-media — Recent Instagram posts from the live event's organizerGET /api/instagram/feed — Curated Instagram feedGET /api/instagram/accounts — Curated account scores with follower historyGET /api/openapi.json — This OpenAPI specification as JSONGET /api/docs — Human-readable API referenceGET /api/spotsPaginated, filterable surf spot list
Full-text search via `q` uses SQLite FTS5 with bm25 ranking; without `q`, ordering is tier-weighted (iconic → regional → other) then alphabetical.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer, default 20 | |
offset | query | integer, default 0 | |
q | query | string | Full-text search (FTS5) |
country | query | string | ISO 3166-1 alpha-2 country code |
region | query | string | |
wave_type | query | string | e.g. reef, beach, point |
skill_level | query | string | e.g. beginner, intermediate, expert |
tag | query | string | Tag slug |
tier | query | string | Curation tier, e.g. iconic, regional |
| Status | Description |
|---|---|
200 | Page of spots plus total match count |
400 | Invalid query parameter |
GET /api/spots/mapLightweight spot markers for the Leaflet map
Unpaginated; optionally constrained to a bounding box. Same filters as the list endpoint.
| Parameter | In | Type | Description |
|---|---|---|---|
bbox | query | string | Bounding box as `min_lat,min_lng,max_lat,max_lng` |
q | query | string | Full-text search (FTS5) |
country | query | string | ISO 3166-1 alpha-2 country code |
region | query | string | |
wave_type | query | string | |
skill_level | query | string | |
tag | query | string | |
tier | query | string | Curation tier, e.g. iconic, regional |
| Status | Description |
|---|---|
200 | Marker payload |
400 | Invalid query parameter |
GET /api/spots/metaFacet counts for the spot filter sidebar
| Parameter | In | Type | Description |
|---|---|---|---|
q | query | string | Full-text search (FTS5) |
country | query | string | ISO 3166-1 alpha-2 country code |
region | query | string | |
wave_type | query | string | |
skill_level | query | string | |
tag | query | string | |
tier | query | string | Curation tier, e.g. iconic, regional |
| Status | Description |
|---|---|
200 | Filtered total plus facet value/count pairs |
400 | Invalid query parameter |
GET /api/spots/{slug}Spot detail with images, tags, and linked surfers
| Parameter | In | Type | Description |
|---|---|---|---|
slug | path | string, required |
| Status | Description |
|---|---|
200 | Spot detail payload |
404 | Resource not found |
GET /api/surfersPaginated, filterable surfer list
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer, default 20 | |
offset | query | integer, default 0 | |
q | query | string | Full-text search (FTS5) |
country | query | string | ISO 3166-1 alpha-2 country code |
discipline | query | string | |
era | query | string | |
tier | query | string | Curation tier, e.g. iconic, regional |
| Status | Description |
|---|---|
200 | Page of surfers plus total match count |
400 | Invalid query parameter |
GET /api/surfers/metaFacet counts for the surfer filter sidebar
| Parameter | In | Type | Description |
|---|---|---|---|
q | query | string | Full-text search (FTS5) |
country | query | string | ISO 3166-1 alpha-2 country code |
discipline | query | string | |
era | query | string | |
tier | query | string | Curation tier, e.g. iconic, regional |
| Status | Description |
|---|---|
200 | Filtered total plus facet value/count pairs |
400 | Invalid query parameter |
GET /api/surfers/{slug}Surfer detail with images and linked spots
| Parameter | In | Type | Description |
|---|---|---|---|
slug | path | string, required |
| Status | Description |
|---|---|
200 | Surfer detail payload |
404 | Resource not found |
GET /api/quotesQuotes wall collection
Full collection, optionally filtered; ordered by id.
| Parameter | In | Type | Description |
|---|---|---|---|
category | query | string | |
surfer | query | string | Surfer slug |
| Status | Description |
|---|---|
200 | Quotes plus count |
GET /api/quotes/randomQuote of the Day
Deterministic day-indexed pick from the featured pool (same quote for everyone on a given UTC day). 404 if the featured pool is empty.
| Status | Description |
|---|---|
200 | The day's quote |
404 | Resource not found |
GET /api/clipsVideo clip inventory
Clips ordered featured-first; `src` is a public R2 URL.
| Status | Description |
|---|---|
200 | Clip list |
GET /api/eventsSurf event calendar with derived status
Season calendar of surf competitions (WSL CT, ISA, big-wave windows). Status (upcoming/live/window-open/completed) is derived from each event's date window at request time, never stored.
| Parameter | In | Type | Description |
|---|---|---|---|
org | query | string | Filter by organization slug (e.g. wsl, isa). |
tour | query | string | Filter by tour/series (e.g. ct, isa-wsg, big-wave). |
year | query | string | Filter by start-date year (e.g. 2026). |
status | query | string | Filter by derived status. |
| Status | Description |
|---|---|
200 | Events ordered by start date |
GET /api/events/live-mediaRecent Instagram posts from the live event's organizer
While an event is live, recent media from the organizing account via Business Discovery (1h D1 cache). Empty payload when no event is live, the organization has no Instagram handle, or Instagram is unconfigured.
| Status | Description |
|---|---|
200 | Live event media rail payload |
GET /api/instagram/feedCurated Instagram feed
Served from the D1 cache (refreshed by a 6-hourly cron); lazily refreshed on cache miss. Returns an empty post list when Instagram credentials are not configured; 503 when a live refresh fails with no cache to fall back on.
| Status | Description |
|---|---|
200 | Feed payload |
503 | Upstream dependency unavailable |
GET /api/instagram/accountsCurated account scores with follower history
Latest score snapshot per curated account, ranked by score then followers, each with a recent follower-count history series. Empty list when Instagram credentials are not configured.
| Status | Description |
|---|---|
200 | Scored accounts |
GET /api/openapi.jsonThis OpenAPI specification as JSON
The spec bundled into the deployed worker at build time, so it always matches the running version.
| Status | Description |
|---|---|
200 | OpenAPI 3.1 document |
GET /api/docsHuman-readable API reference
Dependency-free HTML rendered from the bundled spec at request time.
| Status | Description |
|---|---|
200 | HTML documentation page |