Read API · Start here
Programs
GET /api/v1/programs.json
The Community Radio API serves many programs, and every address names the program: /api/v1/programs/<program>/... where <program> is the program's slug, tbr for Tahoe Backcountry Radio. There are no plain addresses: every address names the program. A program that does not exist is a 404. Each program's data is its own: zones, channels, sign locations, signs, events. A second program appears as its own slug with no change to a client. The directory at /api/v1/programs.json lists every program on this server with its addresses and the attribution a client credits it with.
-
Tahoe Backcountry Radio
tbr
Tahoe Backcountry Ski Patrol, Lake Tahoe, California and Nevada
Credit as: Tahoe Backcountry Radio, via the Community Radio API, CC BY 4.0
Stood up from a spreadsheet in 12 minutes (2026-10-16 PT)
-
Backcountry Radio Program, Front Range
front-range demo
Its organizer's CalTopo map, exported 2026-10-16 PT, shown as a sample.
Backcountry Radio Program, Front Range, a community initiative, Front Range, Colorado
Credit as: Backcountry Radio Program, Front Range, via the Community Radio API, CC BY 4.0
-
Red Mountain Pass
red-mountain demo
Its own public CalTopo map, exported 2026-10-16 PT, shown as a sample.
Red Mountain Pass backcountry radio program, San Juan Mountains, Colorado
Credit as: Red Mountain Pass, via the Community Radio API, CC BY 4.0
Pick an address in the menu to see what it answers and send it from here. The program is a setting on every other address.
The directory of programs on this server, the one address that names no program: every program with its slug, name, the organization that runs it, its region in words, its licence (CC BY 4.0) and the attribution a client credits it with (<name>, via the Community Radio API, CC BY 4.0), whether it is a sample (demo) and, for one, the sentence on where its data comes from (source; null for a program with none), its latest stand-up where one is recorded (the date in PT, whether the files were a spreadsheet or a CalTopo export, and the stand-up clock's minutes), and its addresses, absolute on this server: zones, sign locations, channels, events and the three publishing feeds. The programs people use come first, the samples after. No changed_since: the list is small.
The answer carries generated_at and programs; each program: slug, name, organization, region, licence, attribution, demo, source (null for a program with none), stood_up (date, source, minutes; null until one is recorded), addresses (zones, sign_locations, channels, events, zones_feed, zones_gpx, sign_locations_feed). Content-Type application/json, cached for five minutes.
Read API
Source files
GET /api/v1/programs/<program>/sources/<file>
The files the program was stood up from, as committed in its folder on this server: program.json, the program's facts; zones.csv and trailheads.csv, the two sheet tabs; zones.json, the CalTopo export; redirection.csv, the Redirection export of its short links; stand-up.json, the stand-up clock's records. Those six names and no other: any other name is a 404, as is a program with no folder and a file the folder lacks (the last two are optional). Nothing in them is a person. Tahoe Backcountry Radio's set is the worked example on the Bring your program panel: what a program sends, file by file.
The answer carries The file as committed, byte for byte. Content-Type application/json for program.json and stand-up.json; text/csv for zones.csv, trailheads.csv and redirection.csv; application/geo+json for zones.json; cached for five minutes.
Read API
Zones
GET /api/v1/programs/<program>/zones.geojson
The program's zones: a GeoJSON FeatureCollection, one feature per zone (a Polygon, or a MultiPolygon for a zone drawn in two parts; null geometry for a zone whose outline is not yet drawn) with its slug, name, colour, community channel and privacy code, the boundary in words, and its area (slug, name, channel and privacy code) or null for a zone in no area. An area is a named group of zones with a channel and privacy code shared inside it, as the Front Range program draws its map; Tahoe Backcountry Radio's zones belong to none. Beside the features, areas: the program's areas, each with its slug, name, channel, privacy code and outline (Polygon, MultiPolygon or null), every one whatever changed_since says. After the zones, the program's sign locations as Point features, one per trailhead or hut that has a position, with its slug, name, kind, zone (null outside every zone) and whether the trailhead is established, and no sign states: those are the Sign locations address's. So the collection holds both, and one file is the whole import for a map app. With changed_since, only the zones changed after that time (a zone counts as changed when its area changed) and the sign locations that changed (a sign's state is no change here), and a new generated_at to send next time; nothing changed is an empty features list, not an error. A zone removed is never reported as a change: the import never removes a row, and a client that needs to notice removals reads the whole collection now and then. Every changed-since read looks two minutes back of the time sent, because a row's time is stamped before it becomes visible; a feature served twice costs nothing, since a client keeping a copy replaces a feature by its slug. In the Zones answer a zone and a sign location can share a slug (Rubicon is both), so the copy keeps the Points apart from the zones.
The answer carries generated_at, features and areas; each feature's properties: slug, name, colour, colour_hex, channel, privacy_code, boundary, area, updated_at; each sign location's point: slug, name, kind, zone, established, updated_at; each area: slug, name, channel, privacy_code, outline. Content-Type application/geo+json, cached for five minutes.
Read API
Sign locations
GET /api/v1/programs/<program>/sign-locations.geojson
The program's sign locations: Point features (null geometry for one not yet placed) with kind (trailhead or hut), zone, whether the trailhead is established, the checkpoint's sign state (the state of the trailhead sign bolted to it, support CP; null where no checkpoint stands, a planned one included), and the trailhead signs: their count by sign state, and each with its colour code, support (POLE, POST or CP), number and sign state. With changed_since, only the sign locations changed after that time: a sign location counts as changed when it changed or when any of its trailhead signs did (a sign put up, damaged, missing, or down for the summer), and each feature's updated_at is the later of those. A sign location removed is never reported as a change. Every changed-since read looks two minutes back of the time sent, because a row's time is stamped before it becomes visible; a feature served twice costs nothing, since a client keeping a copy replaces a feature by its slug. In the Zones answer a zone and a sign location can share a slug (Rubicon is both), so the copy keeps the Points apart from the zones.
The answer carries generated_at and features; each feature's properties: slug, name, kind, zone, established, checkpoint, signs (count, states, and list: each sign's sign_type, support, number and state), updated_at. Content-Type application/geo+json, cached for five minutes.
Read API
Channels
GET /api/v1/programs/<program>/channels.json
The sign colours with the community channel each fixes, and the out-of-zone channel for anywhere outside every zone (null when the program has none). A program with no signs, as one stood up from its map alone, publishes no colours (sign_colours is empty): its map's colours fix no channel, and each zone's own channel, in the zones answer, is the one to read. With changed_since after the program's channels last changed (less the same two minutes), the answer is generated_at alone.
The answer carries sign_colours (code, name, hex, channel, privacy_code), out_of_zone_channel (channel, privacy_code, or null) and generated_at. Content-Type application/json, cached for five minutes.
Read API
Trailhead conditions
GET /api/v1/programs/<program>/conditions.json
Every trailhead (sign location) that names its weather stations or its avalanche forecast zone, with what each shows: up high, crest wind and temperature and snow depth; down low, snow; each reading with its station, its source and its time, stale when older than three hours, and the station's backup standing in when it has no recent reading. Then each forecast zone's forecast from the avalanche center, with its danger by elevation band, bottom line and avalanche problems, each problem's location the center's own list. ?demo=1 gives every zone the demo forecast, the center's real forecast of Feb 21, 2026, and every station its reading from that morning, as committed (none where the source keeps none). No changed_since: readings change by the hour. Nothing here is a person.
The answer carries generated_at, demo, out_of_zone, trailheads and forecasts; each trailhead: slug, name, latitude, longitude, zone, forecast, up_high (wind, snow) and down_low. Content-Type application/json, cached for five minutes.
Read API
Events
GET /api/v1/programs/<program>/events.json
The program's published events not over yet, soonest first, or with from every one not over by that day, past ones included: each with its slug, title, activity (slug and name), start and end times, whether it is all day, its status (scheduled or cancelled; a cancelled event stays on the list), the event organizer's name and link, the place in words and a description. No people: not the organizing contact, not the number of places, not who signed up. With changed_since, only the events changed after that time, then each event unpublished or deleted since as its slug, updated_at and published: false, so a client keeping a copy drops it. Times are the event's start and end in UTC; a client shows them in Pacific time (PT). An all-day event runs from midnight to midnight PT of its days.
The answer carries generated_at and events; each event: slug, title, activity, activity_name, starts_at, ends_at, all_day, status, organizer_name, organizer_url, place, description, published, updated_at; each removal: slug, updated_at, published. Content-Type application/json, cached for five minutes.
Publishing feeds
Zones feed
GET /feeds/<program>/zones.geojson
The publishing feed of the zones: a static GeoJSON file, byte for byte what the Zones address answered when the feed was last regenerated, with the same generated_at inside, for a map app that wants a file and no API at all. It is regenerated on the next request after the program's data changed, never on a clock, so it is never older than the data. Cached for an hour, with an ETag; a request with If-None-Match is answered 304 while the file stands. The feed does not take changed_since; a client wanting only changes reads the live address. A program's feed is rendered on its first request; a program that does not exist is a 404.
The answer carries The Zones answer as a file: generated_at, features (the zones, then the sign locations' points) and areas. Content-Type application/geo+json, ETag, cached for an hour.
Publishing feeds
Zones GPX feed
GET /feeds/<program>/zones.gpx
The publishing feed of the zones as GPX 1.1, for a GPS unit or a phone map app that imports GPX without converting: one track per zone, named with the zone and its community channel and privacy code (Rubicon 20-30), the zone's outline as the track's points, and the file's time the generated_at of the Zones answer it was rendered from. A zone whose outline is not yet drawn has no track. Before the tracks, one waypoint per trailhead and hut that has a position, named with the sign location and its zone's channel and privacy code (Rubicon 20-30), or with the program's out-of-zone channel for one outside every zone (5-30), its type trailhead or hut. Kept and regenerated as the GeoJSON feeds are: on the next request after the program's zones or sign locations changed, never on a clock. Cached for an hour, with an ETag; a request with If-None-Match is answered 304 while the file stands. A program's feed is rendered on its first request; a program that does not exist is a 404.
The answer carries The zones as a GPX file: metadata (the program's name, the attribution, the time), one wpt per sign location, its name and type, and one trk per zone, its name and its trkpts. Content-Type application/gpx+xml, ETag, cached for an hour.
Publishing feeds
Sign locations feed
GET /feeds/<program>/sign-locations.geojson
The publishing feed of the sign locations: a static GeoJSON file, byte for byte what the Sign locations address answered when the feed was last regenerated, with the same generated_at inside. Regenerated on the next request after the program's data changed (a trailhead sign's state included), never on a clock. Cached for an hour, with an ETag; a request with If-None-Match is answered 304 while the file stands. No changed_since here; a client wanting only changes reads the live address. A program's feed is rendered on its first request; a program that does not exist is a 404.
The answer carries The Sign locations answer as a file: generated_at and features. Content-Type application/geo+json, ETag, cached for an hour.
Writes
An install
POST /api/v1/programs/<program>/installs
What the install page posts, not public: one install record, the JSON document the phone kept while there was no signal, with the photo as base64 inside it (a JPEG the phone has already shrunk, at most 400 KB decoded). Same origin only, the CSRF token in the X-CSRFToken header, and a signed-in person: recording it sets that trailhead sign installed. The sign exists before its install does; an install naming a sign the location does not have is refused and never adds one. The installer is the signed-in person (installed_by only checks it): their name is stored and never answered back.
The answer carries JSON, never a redirect: 201 stored or 200 already have it (id, sign_location, sign_type, sign_number, support); 400 rejected with the reason in plain words; 401 rejected, sign in again; 403 rejected for a missing or stale CSRF token; 404 rejected for an unknown program; 409 rejected when it was saved for another person than the one signed in; 405 for anything but POST.
The body the page posts, as JSON. There is no Send here: this is what the site's own pages post, not part of the public read API.
{
"id": "<a UUID the phone made>",
"made_at": "2026-10-16T09:30:00-07:00",
"sign_location": "rubicon",
"sign_number": null,
"sign_type": null,
"position": {"latitude": 39.03, "longitude": -120.11},
"accuracy": 8,
"support": null,
"notes": "",
"installed_by": 42,
"photo": {"content_type": "image/jpeg", "base64": "..."}
}
Writes
A patrol report
POST /api/v1/programs/<program>/patrol-reports
What the patrol report page posts, not public: one patrol report of a day at a trailhead, the JSON document the phone kept while there was no signal. Same origin only, the CSRF token in the X-CSRFToken header, and a signed-in patroller or admin. The server attaches the weather: the observation of the SNOTEL station nearest the trailhead for the report's made_at, not its arrival; no reading within three hours is no observation, and the report still saves. The patroller is the session's person and is never answered back; filed_by, the person the page was opened for, must be that person or the report is refused 409 and kept on the phone.
The answer carries JSON, never a redirect: 201 stored or 200 already have it (id, sign_location, weather: state observed, none or pending, station, observed_at, temperature_f, depth_in); 400 rejected with the reason in plain words; 401 rejected, sign in again; 403 rejected without the patroller or admin role or for a missing or stale CSRF token; 409 rejected when filed_by is not the person signed in; 404 rejected for an unknown program; 405 for anything but POST.
The body the page posts, as JSON. There is no Send here: this is what the site's own pages post, not part of the public read API.
{
"id": "<a UUID the phone made>",
"filed_by": 7,
"made_at": "2027-01-17T09:10:00-08:00",
"sign_location": "carson-pass",
"position": {"latitude": 38.6946, "longitude": -119.989},
"accuracy": 8,
"users_seen": 38,
"radios_seen": 17,
"stickers_handed_out": 14,
"cars_parked": 26,
"notes": ""
}
Writes
A check-in
POST /api/v1/programs/<program>/check-ins
What the public website's check-in page (tahoebackcountryradio.org/checkin/) posts: one check-in, the JSON document the phone kept while there was no signal. The server matches it to the nearest trailhead sign within 500 m of the phone's position; farther, or with no position, to the sign location named, or to none. Public and CORS from another origin: no cookie and no CSRF token, a rate limit per client address and a honeypot field (how_did_you_hear) in their place; no sign-in, a check-in may be anonymous. The email, if any, is stored and never answered back. The same id again is already have it, never a second row.
The answer carries JSON, never a redirect: 201 stored or 200 already have it (result, id, sign_location, and with subscribe and an email the email sign-up's offer to sign in); 400 rejected with the reason in plain words; 404 rejected for an unknown program; 429 rejected for too many from one address in a minute; 405 for anything but POST and the preflight OPTIONS.
The body the page posts, as JSON. There is no Send here: this is what the site's own pages post, not part of the public read API.
{
"id": "<a UUID the phone made>",
"made_at": "2026-10-16T09:30:00-07:00",
"position": {"latitude": 39.012, "longitude": -120.124},
"accuracy": 12,
"trailhead": "rubicon",
"trailhead_name": "Rubicon",
"channel_shown": "20-30",
"radio_use": ["Radio check"],
"sign_maintenance": false,
"note": "",
"improvement": "",
"email": null,
"subscribe": false,
"demo": false,
"how_did_you_hear": ""
}
Writes
A usage event
POST /api/v1/programs/<program>/usage
What the site's own pages post, not public: one usage event, one thing a phone did on one screen (opened, started, saved offline, sent), keyed by a random phone id that is never matched to a person, an email or a check-in. Same origin only, with the CSRF header, no sign-in; nothing posted here is ever read back over HTTP. Exactly six fields are accepted; a body carrying any other field is refused whole.
The answer carries JSON: 201 stored or 200 already have it (result, id); 400 rejected with the reason; 403 rejected for a missing or stale CSRF token; 404 rejected for an unknown program; 405 for anything but POST.
The body the page posts, as JSON. There is no Send here: this is what the site's own pages post, not part of the public read API.
{
"id": "<a UUID the page made>",
"phone_id": "<the phone id>",
"screen": "check-in",
"event": "opened",
"made_at": "2026-10-16T09:30:00-07:00",
"ms_since_opened": null
}
Writes
A page view
POST /api/v1/programs/<program>/page-views
What the public website posts, not public: one view of one of its pages, the page's path and nothing else. The day's count for that path goes up by one; no cookie, no phone id, no IP address and no time of the view is kept. Posted from the website's own origin, so it is answered with the read API's CORS header and takes no CSRF token and no sign-in: an open counter, as the usage events are. One field is accepted; a body carrying any other field is refused whole. At most 120 views per path per minute and 200 paths per program per day are counted; the rest are refused.
The answer carries JSON: 200 counted (result, path); 400 rejected with the reason; 404 rejected for an unknown program; 429 rejected, and the view not counted, when the path has been counted 120 times this minute or when 200 paths have already been counted today and this is a new one; 405 for anything but POST and the preflight OPTIONS.
The body the page posts, as JSON. There is no Send here: this is what the site's own pages post, not part of the public read API.
{
"path": "/zone-map/"
}
Writes
An email sign-up
POST /api/v1/programs/<program>/subscribe
What the public website's email sign-up block posts: a first name and an email address, to get the program's season updates. Public, from any origin (CORS), no cookie and no CSRF token, since the form is on another site; in their place a honeypot field, how_did_you_hear, that a person never sees (filled, the answer is an email sign-up's 202 and nothing happens) and a rate limit of five email sign-ups a minute from one client address. The address becomes a subscriber only when the one confirmation email's link is followed; a second email sign-up before that sends another at most once a day. Every email carries an unsubscribe link, which sets the person do-not-mail. Nothing posted here is ever read back over HTTP.
The answer carries JSON, never a redirect: 202 accepted with the sentence to show (Check your email for one message to confirm.) as message, and the optional offer to sign in as offer (text, then link, the words of the link to url, the sign-in page), the same when the honeypot is filled; 400 rejected with the reason as a sentence; 404 rejected for an unknown program; 429 rejected for too many from one client in a minute; 405 for anything but POST.
The body the page posts, as JSON. There is no Send here: this is what the site's own pages post, not part of the public read API.
{
"first_name": "Pat",
"email": "<the address>",
"how_did_you_hear": ""
}
Your program
Bring your program
A program sends four files and one page of facts. Nothing in them is a person: no names, no phones, no emails.
-
The program's facts, one page of facts. The program's name and slug, the organization that runs it, the out-of-zone channel and privacy code, if it has one, and the sign colours, each with its hex and its community channel.
program.json in the worked example
-
Zones, a spreadsheet tab. One row per zone: its name, its sign colour, its channel and privacy code, and its boundary in words. The import reads Name, Color, Channels and Detailed Boundary Definition; the other columns in the worked example are ignored.
zones.csv in the worked example
-
Sign locations, a spreadsheet tab. One row per trailhead or hut: its zone, its name, whether a sign is there and its state, whether it is an established winter trailhead, whether a checkpoint stands there, and its UTM if the map lacks the point. That is nine columns, none a person.
trailheads.csv in the worked example
-
The map, a CalTopo GeoJSON export. The zone outlines as polygons, and a point per trailhead and hut named exactly as the sheet names it. The map wins on position.
zones.json in the worked example
-
Short links, optional. The paths on the program's own host that send people elsewhere (a map, a form), as a Redirection export, if the program has any.
redirection.csv in the worked example
What a program gets back, within a day: its addresses under /api/v1/programs/<slug>/, its three publishing feeds, an entry in the directory, and this explorer showing its data.
Send the files; we load them and hand you your addresses. We run the import, and a program never uploads anything. How long that takes is on the clock: see each program's stand-up time in Programs.
The template
Spreadsheet template (copy it)
The template keeps our sheet's column names, so copy the sheet and start typing: a tab for zones, a tab for sign locations.
The worked example
Tahoe Backcountry Radio's own files are the worked example, the set the live site was stood up from.
Only a map
A program with only a CalTopo map can start from the map alone: zones from the map, no sign locations yet. The sheet can follow.
Your program
Roadmap
By milestone, never by day. The record is a small file beside the registry, refreshed by pull request; when v1 freezes is an item under This winter.
Live now
- The read API: zones, sign locations, channels, one program per address.
- The publishing feeds: the zones and the sign locations as static GeoJSON files, and the zones as GPX.
- Upcoming events, read only.
- The directory of programs with the attribution each is credited with.
- This explorer and the OpenAPI document, from the one registry beside the routes.
- The licence: CC BY 4.0, credited per program.
This winter First snow
- Check-in counts per trailhead per day.
- Sign state history: when each trailhead sign went up, was damaged, went missing, came down.
- More programs on this server, each stood up from its files.
- v1 freezes at first snow: after it, no address changes shape.
On demand
- The management API with per-program keys, built when a second program or an outside tool asks.
- Self-serve import: a region loads its own files.
Later January 2027
- Map tiles, when a map app asks for them.
- A program-neutral address: tbradio.org stays the address this winter, a neutral name when the first outside region signs up.
No changelog yet: the API has not been released.