{"openapi":"3.1.0","info":{"title":"channel-surf-tv API","version":"1.0.0","description":"Read-only JSON API served by the `channel-surf-tv` Cloudflare Worker in front of\nthe D1 database (`channel-surf-tv-spots`). All endpoints are `GET`; any other\nmethod returns `405 METHOD_NOT_ALLOWED`. Unknown `/api/*` paths return\n`404 NOT_FOUND`. Errors share the envelope `{error, code}`. Successful responses\nare cacheable (`Cache-Control: public, max-age=300, stale-while-revalidate=600`)\nand CORS-enabled for allow-listed origins.\n\nThis spec is the source of truth for the API surface: `npm run api:gen` converts\nit to `src/worker/openapi.json` (served live at `/api/openapi.json`) and renders\n`docs/api/API.md`. `npm run health` diffs worker routes against these paths and\nflags drift.\n"},"servers":[{"url":"https://channel-surf-tv.jgpowers52.workers.dev","description":"Production worker"},{"url":"http://localhost:8787","description":"Local wrangler dev"}],"tags":[{"name":"spots","description":"Surf spot catalog (list, map, facets, detail)"},{"name":"surfers","description":"Surfer catalog (list, facets, detail)"},{"name":"quotes","description":"Surf culture quotes wall and Quote of the Day"},{"name":"clips","description":"Video clips served from R2"},{"name":"instagram","description":"Curated Instagram feed and account scoring"},{"name":"meta","description":"API self-description (spec + docs)"}],"paths":{"/api/spots":{"get":{"tags":["spots"],"operationId":"listSpots","summary":"Paginated, filterable surf spot list","description":"Full-text search via `q` uses SQLite FTS5 with bm25 ranking; without `q`, ordering is tier-weighted (iconic → regional → other) then alphabetical.\n","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"},{"$ref":"#/components/parameters/Q"},{"$ref":"#/components/parameters/Country"},{"name":"region","in":"query","schema":{"type":"string"}},{"name":"wave_type","in":"query","schema":{"type":"string"},"description":"e.g. reef, beach, point"},{"name":"skill_level","in":"query","schema":{"type":"string"},"description":"e.g. beginner, intermediate, expert"},{"name":"tag","in":"query","schema":{"type":"string"},"description":"Tag slug"},{"$ref":"#/components/parameters/Tier"}],"responses":{"200":{"description":"Page of spots plus total match count","content":{"application/json":{"schema":{"type":"object","required":["spots","total","limit","offset"],"properties":{"spots":{"type":"array","items":{"$ref":"#/components/schemas/Spot"}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/spots/map":{"get":{"tags":["spots"],"operationId":"mapSpots","summary":"Lightweight spot markers for the Leaflet map","description":"Unpaginated; optionally constrained to a bounding box. Same filters as the list endpoint.","parameters":[{"name":"bbox","in":"query","schema":{"type":"string"},"description":"Bounding box as `min_lat,min_lng,max_lat,max_lng`"},{"$ref":"#/components/parameters/Q"},{"$ref":"#/components/parameters/Country"},{"name":"region","in":"query","schema":{"type":"string"}},{"name":"wave_type","in":"query","schema":{"type":"string"}},{"name":"skill_level","in":"query","schema":{"type":"string"}},{"name":"tag","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/Tier"}],"responses":{"200":{"description":"Marker payload","content":{"application/json":{"schema":{"type":"object","required":["spots"],"properties":{"spots":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"lat":{"type":"number"},"lng":{"type":"number"},"summary":{"type":["string","null"]},"hero_image":{"type":["string","null"]},"hero_qa_tier":{"type":["string","null"]}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/spots/meta":{"get":{"tags":["spots"],"operationId":"spotsMeta","summary":"Facet counts for the spot filter sidebar","parameters":[{"$ref":"#/components/parameters/Q"},{"$ref":"#/components/parameters/Country"},{"name":"region","in":"query","schema":{"type":"string"}},{"name":"wave_type","in":"query","schema":{"type":"string"}},{"name":"skill_level","in":"query","schema":{"type":"string"}},{"name":"tag","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/Tier"}],"responses":{"200":{"description":"Filtered total plus facet value/count pairs","content":{"application/json":{"schema":{"type":"object","required":["filtered_total","facets"],"properties":{"filtered_total":{"type":"integer"},"facets":{"type":"object","properties":{"countries":{"$ref":"#/components/schemas/FacetList"},"regions":{"$ref":"#/components/schemas/FacetList"},"waveTypes":{"$ref":"#/components/schemas/FacetList"},"skillLevels":{"$ref":"#/components/schemas/FacetList"},"tags":{"$ref":"#/components/schemas/FacetList"},"tiers":{"$ref":"#/components/schemas/FacetList"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/spots/{slug}":{"get":{"tags":["spots"],"operationId":"getSpot","summary":"Spot detail with images, tags, and linked surfers","parameters":[{"$ref":"#/components/parameters/Slug"}],"responses":{"200":{"description":"Spot detail payload","content":{"application/json":{"schema":{"type":"object","required":["spot","images","tags","surfers"],"properties":{"spot":{"$ref":"#/components/schemas/Spot"},"images":{"type":"array","items":{"$ref":"#/components/schemas/Image"}},"tags":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}}}},"surfers":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"relation":{"type":["string","null"]}}}}}}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/surfers":{"get":{"tags":["surfers"],"operationId":"listSurfers","summary":"Paginated, filterable surfer list","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"},{"$ref":"#/components/parameters/Q"},{"$ref":"#/components/parameters/Country"},{"name":"discipline","in":"query","schema":{"type":"string"}},{"name":"era","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/Tier"}],"responses":{"200":{"description":"Page of surfers plus total match count","content":{"application/json":{"schema":{"type":"object","required":["surfers","total","limit","offset"],"properties":{"surfers":{"type":"array","items":{"$ref":"#/components/schemas/Surfer"}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/surfers/meta":{"get":{"tags":["surfers"],"operationId":"surfersMeta","summary":"Facet counts for the surfer filter sidebar","parameters":[{"$ref":"#/components/parameters/Q"},{"$ref":"#/components/parameters/Country"},{"name":"discipline","in":"query","schema":{"type":"string"}},{"name":"era","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/Tier"}],"responses":{"200":{"description":"Filtered total plus facet value/count pairs","content":{"application/json":{"schema":{"type":"object","required":["filtered_total","facets"],"properties":{"filtered_total":{"type":"integer"},"facets":{"type":"object","properties":{"countries":{"$ref":"#/components/schemas/FacetList"},"disciplines":{"$ref":"#/components/schemas/FacetList"},"eras":{"$ref":"#/components/schemas/FacetList"},"tiers":{"$ref":"#/components/schemas/FacetList"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/surfers/{slug}":{"get":{"tags":["surfers"],"operationId":"getSurfer","summary":"Surfer detail with images and linked spots","parameters":[{"$ref":"#/components/parameters/Slug"}],"responses":{"200":{"description":"Surfer detail payload","content":{"application/json":{"schema":{"type":"object","required":["surfer","images","spots"],"properties":{"surfer":{"$ref":"#/components/schemas/Surfer"},"images":{"type":"array","items":{"$ref":"#/components/schemas/Image"}},"spots":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"region":{"type":["string","null"]},"country_code":{"type":["string","null"]},"relation":{"type":["string","null"]}}}}}}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/quotes":{"get":{"tags":["quotes"],"operationId":"listQuotes","summary":"Quotes wall collection","description":"Full collection, optionally filtered; ordered by id.","parameters":[{"name":"category","in":"query","schema":{"type":"string"}},{"name":"surfer","in":"query","schema":{"type":"string"},"description":"Surfer slug"}],"responses":{"200":{"description":"Quotes plus count","content":{"application/json":{"schema":{"type":"object","required":["quotes","total"],"properties":{"quotes":{"type":"array","items":{"$ref":"#/components/schemas/Quote"}},"total":{"type":"integer"}}}}}}}}},"/api/quotes/random":{"get":{"tags":["quotes"],"operationId":"quoteOfDay","summary":"Quote of the Day","description":"Deterministic day-indexed pick from the featured pool (same quote for everyone on a given UTC day). 404 if the featured pool is empty.\n","responses":{"200":{"description":"The day's quote","content":{"application/json":{"schema":{"type":"object","required":["quote"],"properties":{"quote":{"$ref":"#/components/schemas/Quote"}}}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/clips":{"get":{"tags":["clips"],"operationId":"listClips","summary":"Video clip inventory","description":"Clips ordered featured-first; `src` is a public R2 URL.","responses":{"200":{"description":"Clip list","content":{"application/json":{"schema":{"type":"object","required":["clips"],"properties":{"clips":{"type":"array","items":{"type":"object","required":["slug","title","src","uploadDate","featured"],"properties":{"slug":{"type":"string"},"title":{"type":"string"},"src":{"type":"string","format":"uri"},"uploadDate":{"type":"string"},"featured":{"type":"boolean"}}}}}}}}}}}},"/api/events":{"get":{"tags":["events"],"operationId":"listEvents","summary":"Surf event calendar with derived status","description":"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.\n","parameters":[{"name":"org","in":"query","schema":{"type":"string"},"description":"Filter by organization slug (e.g. wsl, isa)."},{"name":"tour","in":"query","schema":{"type":"string"},"description":"Filter by tour/series (e.g. ct, isa-wsg, big-wave)."},{"name":"year","in":"query","schema":{"type":"string"},"description":"Filter by start-date year (e.g. 2026)."},{"name":"status","in":"query","schema":{"type":"string","enum":["upcoming","live","window-open","completed"]},"description":"Filter by derived status."}],"responses":{"200":{"description":"Events ordered by start date","content":{"application/json":{"schema":{"type":"object","required":["events","total"],"properties":{"total":{"type":"integer"},"events":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"org":{"type":"string"},"orgName":{"type":["string","null"]},"tour":{"type":["string","null"]},"startDate":{"type":"string"},"endDate":{"type":"string"},"windowType":{"type":"string","enum":["scheduled","holding-period"]},"location":{"type":"string"},"country":{"type":["string","null"]},"spotSlug":{"type":["string","null"]},"url":{"type":["string","null"]},"sourceUrl":{"type":["string","null"]},"summary":{"type":["string","null"]},"status":{"type":"string","enum":["upcoming","live","window-open","completed"]},"results":{"type":["object","null"],"description":"Podium for completed events (curated from the results importer); null when no results are recorded.","properties":{"men":{"type":"array","items":{"type":"object","properties":{"place":{"type":"integer"},"name":{"type":"string"},"surferSlug":{"type":["string","null"]}}}},"women":{"type":"array","items":{"type":"object","properties":{"place":{"type":"integer"},"name":{"type":"string"},"surferSlug":{"type":["string","null"]}}}}}}}}}}}}}}}}},"/api/events/live-media":{"get":{"tags":["events"],"operationId":"liveEventMedia","summary":"Recent Instagram posts from the live event's organizer","description":"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.\n","responses":{"200":{"description":"Live event media rail payload","content":{"application/json":{"schema":{"type":"object","required":["posts"],"properties":{"eventSlug":{"type":["string","null"]},"eventName":{"type":["string","null"]},"username":{"type":["string","null"]},"fetchedAt":{"type":"string"},"source":{"type":"string","enum":["live","cache"]},"posts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"permalink":{"type":"string"},"mediaType":{"type":"string"},"timestamp":{"type":"string"},"caption":{"type":"string"},"mediaUrl":{"type":"string"},"thumbnailUrl":{"type":"string"},"username":{"type":"string"}}}}}}}}}}}},"/api/instagram/feed":{"get":{"tags":["instagram"],"operationId":"instagramFeed","summary":"Curated Instagram feed","description":"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.\n","responses":{"200":{"description":"Feed payload","content":{"application/json":{"schema":{"type":"object","required":["profileUrl","username","fetchedAt","source","posts"],"properties":{"profileUrl":{"type":"string","format":"uri"},"username":{"type":"string"},"fetchedAt":{"type":"string","format":"date-time"},"source":{"type":"string","enum":["cache","live"]},"posts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"caption":{"type":["string","null"]},"mediaType":{"type":"string"},"mediaUrl":{"type":"string","format":"uri"},"permalink":{"type":"string","format":"uri"},"timestamp":{"type":"string"},"username":{"type":"string"}}}}}}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/api/instagram/accounts":{"get":{"tags":["instagram"],"operationId":"instagramAccounts","summary":"Curated account scores with follower history","description":"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.\n","responses":{"200":{"description":"Scored accounts","content":{"application/json":{"schema":{"type":"object","required":["accounts"],"properties":{"accounts":{"type":"array","items":{"type":"object","properties":{"handle":{"type":"string"},"ok":{"type":"boolean"},"followers":{"type":["integer","null"]},"lastPostAt":{"type":["string","null"]},"avgEngagement":{"type":["number","null"]},"engagementRate":{"type":["number","null"]},"active":{"type":"boolean"},"score":{"type":["number","null"]},"scoredAt":{"type":"string"},"history":{"type":"array","items":{"type":"object","properties":{"scoredAt":{"type":"string"},"followers":{"type":"integer"}}}}}}}}}}}}}}},"/api/openapi.json":{"get":{"tags":["meta"],"operationId":"getOpenApiSpec","summary":"This OpenAPI specification as JSON","description":"The spec bundled into the deployed worker at build time, so it always matches the running version.\n","responses":{"200":{"description":"OpenAPI 3.1 document","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/docs":{"get":{"tags":["meta"],"operationId":"getApiDocs","summary":"Human-readable API reference","description":"Dependency-free HTML rendered from the bundled spec at request time.","responses":{"200":{"description":"HTML documentation page","content":{"text/html":{"schema":{"type":"string"}}}}}}}},"components":{"parameters":{"Limit":{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},"Offset":{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},"Q":{"name":"q","in":"query","schema":{"type":"string"},"description":"Full-text search (FTS5)"},"Country":{"name":"country","in":"query","schema":{"type":"string"},"description":"ISO 3166-1 alpha-2 country code"},"Tier":{"name":"tier","in":"query","schema":{"type":"string"},"description":"Curation tier, e.g. iconic, regional"},"Slug":{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}},"responses":{"BadRequest":{"description":"Invalid query parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unavailable":{"description":"Upstream dependency unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","required":["error","code"],"properties":{"error":{"type":"string"},"code":{"type":"string"}}},"FacetList":{"type":"array","items":{"type":"object","required":["value","count"],"properties":{"value":{"type":"string"},"count":{"type":"integer"}}}},"Spot":{"type":"object","properties":{"id":{"type":"integer"},"slug":{"type":"string"},"name":{"type":"string"},"country_code":{"type":["string","null"]},"region":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]},"summary":{"type":["string","null"]},"wave_type":{"type":["string","null"]},"skill_level":{"type":["string","null"]},"best_months":{"type":["string","null"]},"tier":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"},"hero_image":{"type":["string","null"]},"hero_qa_tier":{"type":["string","null"]}}},"Surfer":{"type":"object","properties":{"id":{"type":"integer"},"slug":{"type":"string"},"name":{"type":"string"},"country_code":{"type":["string","null"]},"region":{"type":["string","null"]},"birth_year":{"type":["integer","null"]},"death_year":{"type":["integer","null"]},"stance":{"type":["string","null"]},"discipline":{"type":["string","null"]},"era":{"type":["string","null"]},"summary":{"type":["string","null"]},"tier":{"type":["string","null"]},"hero_image":{"type":["string","null"]}}},"Quote":{"type":"object","required":["slug","text"],"properties":{"slug":{"type":"string"},"text":{"type":"string"},"author":{"type":["string","null"]},"category":{"type":["string","null"]},"sourceUrl":{"type":["string","null"],"format":"uri"},"credit":{"type":["string","null"]},"surferSlug":{"type":["string","null"]}}},"Image":{"type":"object","properties":{"id":{"type":"integer"},"url":{"type":"string","format":"uri"},"alt_text":{"type":["string","null"]},"sort_order":{"type":"integer"},"credit":{"type":["string","null"]}}}}}}