Public JSON API
The iOS app and the web page both read a public, read-only JSON API at
https://sunsetbeach.neuralconfig.com/api/…. It covers exactly one beach —
Sunset Beach, North Carolina — and needs no key and no account. Every
route answers to GET, returns compact JSON with Cache-Control: public, max-age=60, an ETag over the exact bytes and Access-Control-Allow-Origin: *, and honours If-None-Match with a 304. Add ?pretty=1 for indented
output. Fetched panels share one envelope, { data, meta }, where meta says
where the data came from, when it was fetched, and whether it is a
last-known-good copy. Unknown /api/ paths return {"error":"Not found"} with
status 404; anything outside /api/ serves the web app.
The envelope
Section titled “The envelope”Routes that fetch an upstream return either a success or an error envelope:
{ "data": { "...": "the parsed payload" }, "meta": { "source": "NDBC 41024 — Sunset Nearshore (SUN2)", "url": "https://www.ndbc.noaa.gov/data/realtime2/41024.txt", "fetchedAt": "2026-08-16T13:05:12.345Z", "ok": true, "stale": false }}{ "data": null, "meta": { "source": "NDBC 41024 — Sunset Nearshore (SUN2)", "url": "https://www.ndbc.noaa.gov/data/realtime2/41024.txt", "ok": false, "error": "https://www.ndbc.noaa.gov/data/realtime2/41024.txt -> HTTP 503" }}meta field | Meaning |
|---|---|
source | Human name of the upstream. For launches it changes to include “(dev mirror — data may lag)” when the Worker fell back to The Space Devs’ mirror. |
url | The upstream URL or page the data came from. |
fetchedAt | When the served copy actually came off the upstream — not when you asked. For a composite panel, the oldest part’s time. |
ok | true with data; false with data: null and error. |
stale | true when the upstream failed and this is the last-known-good copy from KV. false when live. |
staleReason | Present when stale: the failure that caused the fallback, e.g. "… -> HTTP 429" or "parse failed: …"; for composites, part: reason joined with ; . |
error | Present when ok is false. "unknown error" if nothing more specific was recorded. |
Inside /api/conditions every fetched panel is one of these envelopes under
its own key; computed panels (astro, almanac, planner, parking, beach,
cams, ships, timeline, activities, verdict) have no meta because
they have no upstream.
Routes
Section titled “Routes”All routes are GET on https://sunsetbeach.neuralconfig.com.
Assembled payloads
Section titled “Assembled payloads”| Route | Returns | Caching |
|---|---|---|
/api/conditions | Everything the app shows: site, generatedAt, verdict, activities, astro, surfZone, tides, surge, coastalWaters, tropical, buoyLocal, buoyWaves, buoyNearshore, weather, alerts, marine, launches (with visibleCount, nextVisible), sky, air, events (with public), rivers, marineLife, turtles, almanac, planner, sightings, rightWhales, birds, waterQuality, shellfish, spills, fishing, migration, parking, beach, cams, ships, timeline. Roughly 440 KB compact. | Served from the copy the 5-minute cron publishes; rebuilt only if that copy is over 10 minutes old, so the ETag holds still between ticks. |
/api/summary | The small (under 2 KB) cut the widget reads: generatedAt, top-level stale (true if any of buoy, weather, surf zone, tides or sky is last-known-good), verdict (score, headline, best, warnings), alerts (event names), tide (currentHeightFt, trend, next two extremes), surf (rip risk and level, surf height, water temperature), sun (sunrise/sunset with their ratings), next (the best almanac window in the next 12 hours). | Computed per request from cached sources. |
/api/health | generatedAt, elapsedMs, ok, degraded, and sources — the 28 upstream rows. See How fresh the data is. | Edge-memoised 60 s. |
/api/astro | Sun and moon for the beach right now: sunrise, sunset, solar noon, civil and nautical dawn/dusk, golden hour, sun elevation and azimuth, moonrise/moonset with azimuth, compass point and whether over the ocean, moon phase and illumination, fullMoonOverOcean. No meta. | Per request. |
/api/almanac | windows (the “When to go” windows for the next 7 days), next, year (12 month highlights), thisMonth, horizonDays. | Per request from cached sources. |
/api/planner | The seven days ahead, one column per local day, with the honesty flags wholeZone and coarse. | Per request. |
/api/timeline | Events, launches and sky (sunset, moonrise) as one ordered list, each row with its own sourceName / sourceUrl. | Per request. |
/api/calibration | generatedAt, explains, last30, last90, rows (newest first, up to 90 daily roll-ups) — what the app said against what the sky did. Not about the beach; nothing on the phone reads it. Returns { "error": "No KV binding, so there is no record to report." } if the Worker has no KV. | Edge-memoised 300 s. |
One upstream at a time
Section titled “One upstream at a time”Each returns the { data, meta } envelope for that source. Details of each
upstream are on Where every number comes from.
| Route | Source |
|---|---|
/api/tides | CO-OPS 8659897 predictions: extremes, series, currentHeightFt, trend, nextExtreme |
/api/buoys | Not an envelope itself but three: { local, waves, nearshore }, one per NDBC buoy |
/api/surfzone | NWS Surf Zone Forecast NCZ110, parsed by day |
/api/surfzone/raw | The NWS product as plain text (Content-Type: text/plain), or the body unavailable. The one route that is not JSON and carries no ETag. |
/api/marine | Open-Meteo Marine wave forecast |
/api/launches | Launch Library 2 launches with the visibility model applied |
/api/weather | NWS gridpoint ILM 70,57 |
/api/alerts | NWS active alerts for the beach |
/api/sky | Open-Meteo radiation, UV, layered cloud, sunset/sunrise quality |
/api/air | Open-Meteo Air Quality (US AQI) |
/api/events | Town calendar, Sound Waves series and pier concerts merged |
/api/rivers | USGS gauges |
/api/marinelife | OBIS records for six species |
/api/turtles | Turtle Watch season totals plus the computed season model |
/api/sightings | iNaturalist community observations |
/api/rightwhales | NOAA NEFSC slow zones and critical habitat |
/api/birds | eBird recent and notable (or configured: false without a key) |
/api/birdcast | BirdCast nightly migration for Brunswick County |
/api/waterquality | NC DEQ swim advisories and enterococcus results |
/api/shellfish | NCDEQ closures and classifications |
/api/spills | NCDEQ sanitary sewer overflows near the beach |
/api/tropical | NHC Atlantic Tropical Weather Outlook |
/api/surge | Springmaid Pier observed minus predicted |
/api/coastalwaters | NWS Coastal Waters Forecast AMZ252 |
/api/fishing | Catch report, seasonality almanac and limits together |
/api/pierreport | The pier’s daily report on its own |
/api/proclamations | N.C. DMF finfish proclamations in effect |
Curated, no upstream
Section titled “Curated, no upstream”| Route | Returns |
|---|---|
/api/parking | The paid-parking status computed from the town ordinance for right now. No meta. |
/api/cams | The four beach-camera links (name, operator, URL, note). |
/api/ships | The three kinds of traffic and three tracker links. |
Caching headers and revalidation
Section titled “Caching headers and revalidation”- Every JSON response, including errors, carries
ETag,Cache-Control: public, max-age=60andAccess-Control-Allow-Origin: *. - The
ETagis a SHA-256 of the served bytes, truncated to 128 bits and quoted.?pretty=1and compact output therefore have different tags. - A request whose
If-None-Matchmatches (weak comparison;*matches anything) gets304 Not Modifiedwith no body — but only for a200. A 404 or 500 is never revalidated. /api/conditionsis the payload where this matters: because the cron publishes one copy that every request serves verbatim, consecutive requests return the same tag and a conditional request costs a zero-byte body instead of ~440 KB.- Any Cloudflare-visible client works; the Worker sends its own descriptive
User-Agentto upstreams, and nothing about your request is forwarded to them.
Errors
Section titled “Errors”| Status | Body | When |
|---|---|---|
404 | {"error":"Not found"} | Any /api/ path not listed above. |
500 | {"error":"<message>"} | An unexpected exception while building a payload. Upstream failures do not produce this — they are reported inside the envelope as ok: false or stale: true with a 200. |
200 with data: null | The envelope’s error form | The upstream failed and no usable last-known-good copy exists. Check meta.error. |
200 with stale: true | The envelope’s success form | The upstream failed; you are reading the last good copy. Check meta.fetchedAt. |
Failures that are not error statuses, in other words, are the normal case for
this API: always read meta.ok and meta.stale, not just the HTTP status.
Usage notes
Section titled “Usage notes”- No authentication, no API key, no rate limit is enforced by the Worker
itself. It is a small public API for one beach; the app fetches
/api/conditionson launch and on pull-to-refresh, revalidating with theETagit holds, and the widget reads/api/summaryon the system’s schedule. Please stay in that neighbourhood. - Everything is already cached and warmed on the Worker; you never trigger an upstream fetch of your own except on a cold edge, so hammering a route buys no fresher data than the cadences on How fresh the data is.
- Timestamps are ISO-8601 UTC. The beach observes US Eastern time
(
America/New_York); localise on your side. - Payloads deliberately carry
caveat,noteandpromisesstrings. They are part of the data — the app shows them behind its ⓘ buttons — and a client that drops them is making claims the source doesn’t.