API reference
The public contract, in one page.
Conventionsbase URL https://data.sierragridteam.org,
everything under /api/v1/ · read-only, keyless, CORS-open ·
responses are camelCase throughout, timestamps RFC 3339, errors gRPC-standard
{code, codeName, message, details}, most reads carry an
ETag · every GET below is a live link into the running
service · a feed's sourceStatus is
OK, STALE or
UNAVAILABLE; an unavailable feed returns UNAVAILABLE with no
data, never an empty OK (§6)
1. Overview & conventions
Base URL and media types
/api/v1/observedAt, areaLabel, nextPageToken) — the gRPC RPCs via the gateway marshaler and the hand-built .geojson layers via matching camelCase keys (sourceStatus, severityRank).geojson path extension marks the RFC 7946 projection — a pure path, no headers needed, so map clients can consume the URL directlyConventions
- Timestamps are RFC 3339 strings (e.g.
2026-07-05T14:02:00Z). - Enum values render as their proto names (
WILDFIRE,ACTIVE,SEVERE). Query parameters accept them case-insensitively, andlayerparameters additionally accept the lowercase layer slugs used in.geojsonpaths (wildfire). - Repeatable filters: parameters documented as repeatable may appear multiple times —
?layer=wildfire&layer=evacuation. - Pagination is cursor-based on the event lists —
events,events/{id}/history, andhistory: pass the opaquenextPageTokenfrom a response as thepage_tokenquery parameter on the next request (base64 keyset cursors; treat them as opaque). The directory lists —places,sources,scanners, andcameras— are not paginated: each returns its complete set in one response by design (a bounded, slowly-changing directory), with nonextPageToken. Read them whole; don't build paging logic for them. - Query parameters stay snake_case regardless of response casing:
severity_min,page_token,page_size(camelCase forms are also accepted). - ETags: most read endpoints carry a weak
ETag; a conditional request with a matchingIf-None-Matchgets304 Not Modifiedand skips the work. Coverage:events/{id}+.../history, theeventsandhistorylists,places+places/{place},cameras, and the.geojsonlayers (body-hash). Not yet instrumented:conditions,sources,places:resolve,scanners, andsummary. Independently, every proto RPC carriesCache-Control: public, max-age=30, so a proxy/CDN may serve any of them up to 30 s stale (the.geojsonlayers usemax-age=60). - Errors from the proto RPCs are gRPC-standard
{code, codeName, message, details}alongside the mapped HTTP status; the one hand-built endpoint (.geojson) emits the oldergoogle.rpc.Statusshape ({code, message, details}, nocodeName):
HTTP/1.1 404 Not Found
Content-Type: application/json
{"code": 5, "codeName": "NOT_FOUND", "message": "event not found"}
code is the numeric gRPC status code
(5 = NOT_FOUND, echoed as
codeName). Clients should key on the
HTTP status and surface message.
- Canonical client sort for events:
severitydescending, thenobservedAtdescending. - Places are addressable by slug or id:
ebbetts-passandarea:ebbetts-passboth work anywhere a{place}appears. Slugs are globally unique (§4).
CORS
All /api/v1 endpoints are served with open CORS —
any origin can fetch them from a browser. This site is itself an
unprivileged client of that same surface: every number on every page here
is a same-origin browser fetch you can replay with curl (see the footer of
any page).
2. Endpoint reference
Two idioms, one rule: entities (events, places, sources, conditions, scanners, cameras) are global collections filtered by query parameter; place-derived documents (summary, map layers) nest under the place path. The two hot paths — summary and map layers — take no query string at all, so CDN cache keys stay clean.
Every entry expands to its parameter table and example requests. The examples are not illustrations: press RUN and the page fetches that exact URL live and prints the real response, status, timing and byte count. If an example has gone stale, it fails here.
building the endpoint reference…
3. The Event envelope
Every discrete occurrence is one grid.v1.Event
message. The common envelope is identical across layers — a client can
render any event's card from
headline / severity / provenance / observedAt
without knowing its kind. Typed per-kind data lives in exactly one
detail block.
Fields, beside a live record
fetching a sample record…
Every field
| Field | Type | Semantics |
|---|---|---|
| id | string | Globally unique, source-namespaced: {namespace}:{native_id}. Namespaces: chp: (CHP road incidents), caltrans: (Caltrans lane closures, one per work window: caltrans:d{district}-{window}), wx: (weather alerts), usgs: (earthquakes), calfire: (CAL FIRE incidents), firis: (fire perimeters), evac: (evacuation zones), pge: (PG&E outages), psps: (Public Safety Power Shutoffs, psps:{eventId}:{timePeriod}), meshcore: (mesh nodes, keyed by Ed25519 public key). FIRIS standalone ids are firis:{normalized-name} with -2, -3 disambiguators. |
| layer | Layer | Layer taxonomy (table below) |
| category | string | Source sub-type slug ("active", "order", …) |
| severity | Severity | Unified 5-level scale (table below) |
| status | EventStatus | Lifecycle state (table below) |
| headline | string | Card-renderable one-liner, kind-agnostic |
| summary | string | AI-enhanced 2–3 sentences where applicable (see enhancement) |
| description | string | Long form / original upstream text, always preserved verbatim |
| areaLabel | string | Human area label ("Hathaway Pines & Avery") |
| canonicalUrl | string | Upstream detail URL; scheme-validated — only https:///http:// survive |
| geometry | Geometry | May be absent (county-wide advisories). See the base64 note below. |
| placeIds | repeated string | Place intersections, precomputed at ingest (§4 ids) |
| provenance | Provenance | sourceId, sourceName, attribution, sourceUrl, fetchedAt |
| effective | Timestamp | When the event takes effect (future for SCHEDULED) |
| expires | Timestamp | Upstream expiry, when the source provides one |
| observedAt | Timestamp | Upstream last-update time |
| ingestedAt | Timestamp | When this service ingested the current revision |
| revision | uint32 | Monotonic per event; a new revision is written only on content change |
| enhancement | Enhancement | AI-enhancement provenance: model, enhancedAt, fields (which Event fields were generated), and the model I/O — request (the prompt sent) and response (the raw structured JSON returned). Present only on enhanced events — clients badge AI-summarized text, show what was sent/returned, and can always fall back to the verbatim description. Persisted with the event (in every revision); excluded from the content hash. The heavy request/response are omitted unless ?enhancement_io=true is passed (event endpoints); the lightweight fields are always present. |
| detail | oneof | Exactly one typed block, keyed by kind (below) |
Detail blocks (the oneof)
Each block carries only kind-specific fields. Anything
already in the envelope is not repeated: the incident/alert type is
category, the human location is
areaLabel, the short line is
headline, the sending office is
provenance.sourceName, and the source page is
canonicalUrl.
| Key | Fields |
|---|---|
| wildfire | acres (double), containment (int32), county, cause (string), hasPerimeter (bool) |
| evacuation | zoneId, level (ORDER | WARNING | ADVISORY | SHELTER_IN_PLACE, as string), eventType, county (string) |
| weatherAlert | nwsSeverity, certainty, urgency, instruction, areaDesc (string), zones (repeated string) |
| earthquake | magnitude (double), depthKm (double), felt (int32) |
| roadIncident | logNumber (string), impact, duration (string), metadata (map<string,string>), closure (Caltrans only: windowId, district, closureId, logNumber, route, direction, facility, closureType, workType, lanesClosed, totalLanes, estimatedDelayMinutes, closureDuration, plannedStart, plannedEnd, endIndefinite, setUpAt, beginLocation, endLocation, begin, end) |
| power | outageId, cause (string), customersAffected (int32), crewStatus (string), estimatedRestoration (timestamp); PSPS adds eventId, eventName, timePeriod, stage (Watch | Warning, as string), medicalBaselineAffected (int32), deEnergizationStart, deEnergizationEnd, allClear (timestamp). estimatedRestoration, deEnergizationEnd and allClear are ESTIMATES PG&E routinely overruns — they are deliberately not mapped onto expires, so never use them to hide an event. |
| burnStatus | burnDay (BURN_DAY_UNKNOWN | _YES | _NO | _MARGINAL), calfireStatus (CALFIRE_BURN_STATUS_UNKNOWN | CALFIRE_BURNING_SUSPENDED | CALFIRE_PERMIT_REQUIRED | CALFIRE_NO_PERMIT_REQUIRED), permission (BURN_PERMISSION_UNKNOWN | _ALLOWED | _PROHIBITED), calfireEffective, calfireObservedAt (timestamp), calfireArea (string), burnLines[] { id, name, phone (string), burnDay, observation { message, transcript (string), confidence (int32), observedAt (timestamp) } }. burnLines is a LIST — a county can have more than one recorded line, and one air-district line can speak for several counties. The top-level burnDay is their merge (most restrictive wins); render the per-line answers when they differ. A line we publish but do not read still appears, with burnDay UNKNOWN and its phone populated, so you can always offer the number. permission is deliberately pessimistic — PROHIBITED is conclusive from either authority alone, but ALLOWED needs BOTH facets known and permissive; anything else is UNKNOWN. Render UNKNOWN as "call the burn line", never as a green light. BURN_DAY_MARGINAL means ELEVATION-RESTRICTED (a burn day only above a stated elevation, typically 3500 ft) — only observation.message carries the threshold. The burn-day facet is a transcription of a recorded county phone line, not an API, so observation.confidence is a model's confidence and burnLinePhone should always be offered next to it. |
| mesh | publicKey (hex string), nodeType (companion | repeater | room_server | sensor), name (string), reachability (MESH_REACHABILITY_UNSPECIFIED | REACHABLE | UNREACHABLE), telemetry { snr (double), rssi (int32), hopCount (uint32), gateways (repeated string), lastAdvertAt (timestamp), lastHeardAt (timestamp), lastRelayedAt (timestamp), admin { … } }. Relay paths are not on the event: they are per reception, served as topology at /api/v1/mesh/links. |
Unit-bearing fields name their unit (depthKm).
A fire_weather detail slot is reserved in the
schema but fire weather is condition-backed (a map layer, not an event
stream). The mesh block's
telemetry is the last-heard signal state and is
volatile — it never mints a new event revision (only a node's identity,
role, name, location, reachability, or status does). For "is this node
talking", read telemetry.lastRelayedAt (when a
flood packet it relayed last reached the Grid) and
telemetry.lastHeardAt (when its own advert
did): both the Grid's clock, filled in when the event is served rather than
stored in it, null when none is on record. Not the event's
observedAt, which moves only with a revision
and can be days old for a node relaying right now; and not
telemetry.lastAdvertAt, the node's
self-reported stamp, diagnostic only (mesh node clocks skew). The burnStatus block's
observation is volatile in the same way: the
county line's message names the date, so it changes daily even when the
answer does not, and it never mints a revision. Slots are also reserved for
future layers: gauge, air_quality, announcement.
Enum: Layer
| Name | # | Slug | Backing |
|---|---|---|---|
| WILDFIRE | 1 | wildfire | events |
| EVACUATION | 2 | evacuation | events |
| WEATHER_ALERT | 3 | weather_alert | events |
| FIRE_WEATHER | 4 | fire_weather | conditions (map layer only) |
| EARTHQUAKE | 5 | earthquake | events |
| ROAD_INCIDENT | 6 | road_incident | events |
| POWER | 10 | power | events (PG&E outages + Public Safety Power Shutoffs; category is unplanned | planned | psps). The statewide median outage affects ONE customer, so most rows are INFO — pair with severity_min. INFO power events are excluded from the place-summary rollup (they still appear here and in the power domain). |
| MESH | 13 | mesh_node | events (MeshCore presence) — ?layer= accepts mesh, mesh_node, or the legacy network |
| BURN_STATUS | 15 | burn_status | events (per-county residential burning status). Not a map layer — these events carry no geometry, so there is no burn_status.geojson. Ambient INFO state: like MESH it is excluded from the place-summary rollup (totalActive, severityCounts, topEvents, mode) and appears in its own burn domain. The event is permanently ACTIVE — its revision history is the answer to "when did this change". |
| GAUGE, AIR_QUALITY, ANNOUNCEMENT | 11, 12, 14 | — | reserved for future pollers |
road_segment and
chain_control are condition-backed map-layer
slugs only — they are projections of live road state, not Layer enum
values, and never appear in /api/v1/events.
camera is a map-layer slug only too: Caltrans
traffic cameras near the place, every feature INFO
— reference views, not hazards, and never part of the place summary. It
serves the same cameras and distances as
/api/v1/cameras?place=, with the live
imageUrl and HLS streamUrl
(links to Caltrans, never proxied) under properties.camera.
Enum: Severity (the canonical scale + color ramp)
One comparable scale across every source. It expresses response urgency to the public, not physical magnitude — an Evacuation Order (EXTREME) intentionally outranks an M5 earthquake (SEVERE). Rank equals the enum value. Always pair the color with the text label — color is never the only signal.
| Severity | Rank | On paper | On ink | Badge |
|---|---|---|---|---|
| EXTREME | 4 | #b3261e | #ff5544 | EXTREME |
| SEVERE | 3 | #c2410c | #e0913f | SEVERE |
| MODERATE | 2 | #a16207 | #d0a24a | MODERATE |
| MINOR | 1 | #4d7c0f | #4d9c3a | MINOR |
| INFO | 0 | #1d4ed8 | #3d8bff | INFO |
There are two ramps, not one: the same severity resolves to a print-weight colour on paper and a lighter, foreground-tuned one inside the black data panes, because no single value is legible on both. Ranks and enum names are identical across them — only the rendering differs, and the swatch is only ever a second signal beside the always-present text label.
Enum: EventStatus (lifecycle)
SCHEDULED ──▶ ACTIVE ──▶ RESOLVED (upstream confirmed over)
└──▶ EXPIRED (aged out; upstream went quiet)
| Status | # | Meaning |
|---|---|---|
| SCHEDULED | 1 | effective is in the future — e.g. an NWS watch issued ahead of its window, a planned PSPS, a Caltrans roadwork window not yet set up (which can stay SCHEDULED past its planned start while the crew is late) |
| ACTIVE | 2 | In effect now |
| RESOLVED | 3 | Disappeared from an authoritative active-only feed (Cal OES, CAL FIRE, CHP, Caltrans) — upstream confirmed it over |
| EXPIRED | 4 | Missing from feed and past expires (or a per-source grace period) — sources that don't confirm endings (NWS, FIRIS) |
Every status transition writes a revision — the all-clear is part of
history. By default /api/v1/events returns only
ACTIVE,SCHEDULED; closed events remain
queryable by passing status= explicitly and
through /api/v1/history.
Geometry and the base64 note
Event.geometry is
{geojson: bytes, bbox: {minLat, minLng, maxLat,
maxLng}, centroid: {lat, lng}}. The
geojson field is a raw RFC 7946 geometry
object stored as proto bytes — protojson
renders bytes as base64, so decode before parsing:
const g = event.geometry; // may be absent entirely const geom = g?.geojson ? JSON.parse(atob(g.geojson)) : null; // geom is an RFC 7946 geometry object — coordinates are [lng, lat]
bbox and centroid
are always populated at ingest when geometry exists, so list views never
need to decode. For map rendering, prefer the
.geojson endpoints —
the base64 field exists for detail views.
4. Places & the id scheme
Place ids are {kind}:{slug}. Slugs are
globally unique, so every place is addressable by bare slug
(ebbetts-pass) or full id
(area:ebbetts-pass) anywhere a
{place} path segment or
place= parameter appears. Area slugs are short,
stable ids (e.g. ebbetts-pass).
| Prefix | Kind | # | What | Geometry | Example |
|---|---|---|---|---|---|
| area: | AREA | 1 | Configured service areas (the legacy hazard areas; org footprints live here too) | bbox polygon | area:ebbetts-pass |
| county: | COUNTY | 2 | Service-area counties (Calaveras, Tuolumne, Amador, Alpine, Stanislaus, San Joaquin, El Dorado, Mariposa) — simplified Census TIGERweb polygons | polygon | county:calaveras-county |
| town: | TOWN | 3 | The monitored towns (the weather locations) | point | town:arnold |
| evac_zone: | EVAC_ZONE | 4 | Evacuation zones (Zonehaven/Genasys scheme). Full zone-inventory import is pending; active zones appear as events in the meantime. | polygon | — |
| corridor: | CORRIDOR | 5 | The monitored road corridors | linestring | corridor:hwy4-arnold-bearvalley |
| site: | SITE | 6 | Specific sites (reserved) | point | — |
Each Place carries
id, kind, name, slug, geometry, parentId.
Parent links: towns point at their county; areas have no parent.
/api/v1/places:resolve returns containing places
ordered most-specific-first:
SITE, EVAC_ZONE, TOWN, CORRIDOR, COUNTY, AREA.
5. The GeoJSON layer contract
Each /api/v1/places/{place}/map/{layer}.geojson
response is one RFC 7946 FeatureCollection
with a foreign top-level metadata member
(allowed by RFC 7946, ignored by map libraries, available to clients that read it):
{
"type": "FeatureCollection",
"features": [ … ],
"metadata": {
"layer": "evacuation",
"area": "ebbetts-pass",
"generatedAt": "2026-07-05T15:42:11Z",
"sourceStatus": "OK", // OK | STALE | UNAVAILABLE
"lastSourceUpdate": "2026-07-05T15:38:00Z",
"attribution": "Cal OES / California County Governments — reference only",
"sourceUrl": "https://protect.genasys.com/…",
"schemaVersion": 1
}
}
metadata.sourceStatus
A source error never becomes an empty OK response. The three states render differently and must not be collapsed:
| sourceStatus | Features | Required client rendering |
|---|---|---|
| OK | current | Normal. |
| STALE | last-good served | Render with a "data ~N min old" indicator (N from generatedAt − lastSourceUpdate). |
| UNAVAILABLE | empty | Suppress the map layer; show an empty-state banner linking metadata.sourceUrl. Never render as "all clear". |
The properties envelope
Every feature, in every layer, shares one common envelope — that is the
unification. A client renders any feature's card from
headline / severity / source / updatedAt
without knowing its kind:
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-120.5402, 38.0674] },
"properties": {
// ---- common envelope (present on every feature) ----
"id": "calfire:2026-salt-springs", // globally unique, source-namespaced
"layer": "WILDFIRE", // Layer enum name (same value as event.layer)
"kind": "Wildfire", // human label
"category": "active", // source sub-type slug (free-form, lowercase)
"severity": "SEVERE", // unified scale, §3
"severityRank": 3, // 0..4, for sort/color
"headline": "Salt Springs Fire — 1,377 ac, 20% contained",
"description": "…", // optional, long
"summary": "…", // optional: AI plain-language summary (same as event.summary)
"summaryEnhanced": { "enhancedAt": "2026-07-05T15:41:00Z" }, // with summary when a model wrote it
"status": "ACTIVE", // EventStatus enum name (lifecycle)
"effective": "2026-07-05T14:02:00Z", // RFC 3339, nullable
"expires": null,
"updatedAt": "2026-07-05T15:40:00Z",
"areaLabel": "Hathaway Pines & Avery", // optional
"source": {
"id": "calfire", "name": "CAL FIRE",
"url": "https://www.fire.ca.gov/incidents/…",
"attribution": "CAL FIRE / FIRIS",
"fetchedAt": "2026-07-05T15:42:10Z"
},
// ---- per-kind typed block (only the relevant one) ----
"wildfire": { "acres": 1377, "containment": 20, … }
}
}
- Per-kind data lives under a single namespaced key (
wildfire,earthquake,evacuation,weather,incident,power,mesh,camera) — consumers ignore kinds they don't handle; no field collisions. - Coordinate order is
[longitude, latitude](GeoJSON axis order), WGS84, trimmed to 5 decimals (~1.1 m). Polygons are simplified to keep features small. - Null geometry is valid (e.g. a county-wide advisory): the feature has
"geometry": nullwith the same properties envelope. Exclude it from the map layer and render it as a full-width banner/list card sorted byseverityRankalongside located features, showingheadline,source.name,updatedAt, andsource.url. - URL fields are scheme-validated upstream: any upstream-sourced URL that is not
https:///http://is dropped before it reaches the response. Treat all upstream text (headlines, descriptions) as untrusted anyway. summarysits besidedescription, never instead of it. It is present only when the event has an AI-written summary (the key is omitted otherwise — fall back todescription), andsummaryEnhancedmarks it as machine-written so you can badge it. Onroad_incidentthedescriptionis already the readable narrative, so the two are usually equal there. Onweather_alert,weatheralso carries the NWSnwsSeverity,certainty,urgencyand the verbatiminstruction; the summary never restates the instruction, so render it yourself.- Unit-bearing fields name their unit (
windGustMph,depthKm). layerhas two forms, by role. As a value —properties.layerhere, andevent.layer/topEvents[].layerelsewhere — it is the UPPERCASE Layer enum name (WILDFIRE). As an address — the{layer}path segment, the?layer=query value, and themetadata.layer/metadata.areaechoes above — it is the lowercase slug (wildfire). One rule: values are enum names, URLs and the metadata block are slugs. Mesh-node presence is the one layer whose slug (mesh_node) is not just the lowercased enum name (MESH): as a?layer=value the query acceptsmesh(the enum name),mesh_node(the slug), and the legacynetwork— all resolve to the same layer. (Before 2026-07-25 this layer's enum wasNETWORK; it was renamed toMESH, withnetworkkept as a legacy query alias.)- This endpoint is not in the OpenAPI spec. The
.geojsonlayers are hand-built (RFC 7946 geometry doesn't model cleanly in proto3), so a client generated from the spec alone will be missing all eight map layers — integrate them from this section.
6. The evacuation fail-loud contract
An error never becomes a 0.
Evacuation data is life-safety data, and it is reference only. This service surfaces the presence of orders from Cal OES and links out to the authoritative source; it never asserts that an area is safe. Every consumer of this API inherits the obligations below.
The invariant, mechanically
In /api/v1/places/{place}/summary,
summary.activeEvacuations is
int | null and
summary.evacuationStatus is
OK | STALE | UNAVAILABLE:
| activeEvacuations | evacuationStatus | Meaning | Required client rendering |
|---|---|---|---|
| null | UNAVAILABLE | The Cal OES feed errored. Unknown — not zero. | "Evacuation status unknown — check Genasys Protect" (or the sourceUrl provided). Never an all-clear, never a blank. |
| 0 | OK | Cal OES fetched cleanly and reports no active zones — a caveated confirmed-empty. | "No active evacuations reported (per Cal OES)" — still linked to the source, never phrased as a guarantee. |
| n > 0 | OK / STALE | Active zones exist (STALE = counted from the last good fetch). | Show the count and the zones; if STALE, show data age. |
null and 0 are
different values with different meanings. The API never collapses one
into the other, and clients must not either. An UNAVAILABLE evacuation
source also forces the area mode to at least
WATCH.
The same rule applies to the evacuation map layer
(/api/v1/places/ebbetts-pass/map/evacuation.geojson):
sourceStatus: UNAVAILABLE comes with empty
features and must render as an unknown-state banner, never an empty
(implicitly safe) map. metadata.sourceUrl
links the authoritative Genasys viewer in every state.
Finally: directive life-safety text is never rewritten.
Evacuation orders and instructions appear verbatim in
description — AI enhancement is prohibited
for this layer. Render orders verbatim, with the link out.
The summary shape (quoted from the contract)
{
"place": "ebbetts-pass", "placeId": "area:ebbetts-pass",
"placeName": "Calaveras County", "generatedAt": "RFC3339",
"mode": "QUIET|WATCH|ACTIVE",
"summary": {
"highestSeverity": "SEVERE", "highestSeverityRank": 3,
"severityCounts": {"SEVERE": 1}, "totalActive": 4,
"activeEvacuations": null, "evacuationStatus": "UNAVAILABLE",
"topEvents": [{"id","layer","severity","severityRank","headline","source"}]
},
"domains": [{"domain","status","highestSeverity","activeCount",
"headlines":[{"id","severity","headline"}]}],
"sources": [{"id","status","lastSuccessAt"}]
}
7. Source registry
Every event and every layer traces to one of these sources — the registry
behind /api/v1/sources (live board:
Sources). Each
Source carries
id, name, attribution, homepageUrl,
pollIntervalSeconds, staleAfterSeconds, expireAfterSeconds,
lastSuccessAt, lastAttemptAt, lastError, status.
A source is STALE once its last success is
older than staleAfterSeconds (default 3×
its poll interval) and UNAVAILABLE on
persistent failure.
| id | Provides | Event ids | Poll | Disappearance policy |
|---|---|---|---|---|
| nws | National Weather Service zone alerts (api.weather.gov) — weather_alert events; also the authority behind fire_weather conditions | wx: | 5m | expire — missing and past expires ⇒ EXPIRED |
| usgs | USGS earthquake feed for the service-area bounds | usgs: | 5m | — (see notes) |
| calfire | CAL FIRE active incidents (acres, containment) | calfire: | 5m | resolve — active-only feed; missing ⇒ RESOLVED |
| firis | CAL FIRE / FIRIS fire perimeters (joined to CAL FIRE incidents by name; standalone perimeters get firis: ids) | firis: | 5m | expire |
| caloes | Cal OES California Evacuation Aggregation Layer (Zonehaven/Genasys zone schema) — reference only, see §6 | evac: | 2m | resolve |
| chp | CHP incidents (AI-enhanced road_incident events) | chp: | 5m | resolve |
| caltrans | Caltrans lane closures from the CWWP2 portal (road_incident events, one per work window; SCHEDULED until set up); also attributed for chain-control conditions | caltrans: | 10m | resolve |
| pge | PG&E electric outages (planned and unplanned), affected-area polygons joined onto the reported point | pge: | 5m | resolve — active-only feed; missing ⇒ RESOLVED |
| psps | PG&E Public Safety Power Shutoff coverage; one event per de-energization window, empty outside an event | psps: | 5m | resolve |
| meshcore | Mesh-node presence, from community MeshCore MQTT bridges and operator-run monitors (§8). Every mesh event stays on this source whichever input observed it. | meshcore: | 60s | expire — a mesh has no goodbye packet, so silence is not proof of absence |
| reporter ids | One row per configured push reporter (§8), e.g. alan-pi. Health only — no event is attributed to a reporter; the row answers "is that monitor still reporting?" | — | — | — |
A poller may update multiple source rows (the wildfire tick updates
calfire + firis;
the PG&E tick updates pge +
psps) — poller ≠ source. Poll intervals
and staleness thresholds are server configuration; read the live values
from /api/v1/sources rather than
hard-coding this table.
Attribution requirements
- Display the
attributionstring (fromSource.attribution,Event.provenance.attribution, ormetadata.attributionon GeoJSON layers) wherever you render the data. - Link the upstream:
provenance.sourceUrl(events) /metadata.sourceUrl(GeoJSON) on records,homepageUrlon the source. - Evacuation data must carry its reference-only framing and the Genasys link in every state (§6).
8. Push ingest (operator-reported data)
This is the only write endpoint on the API, and the only one that
requires a credential. Everything else on /api/v1
is public, read-only and keyless; nothing below changes that. It exists for
data no upstream feed publishes — an operator's monitor that logs into
MeshCore repeaters and reads their admin interface, which is the only way to
obtain battery, airtime and packet counters, and the only input that can
report a node it tried to reach and could not.
POST /api/v1/ingest/{stream}
Requires Authorization: Bearer <token>. Tokens
are issued per reporter by the operator; only a SHA-256 hash of the token is
stored in configuration, so the credential itself never appears in this
project's public repository. The endpoint is not mounted at all unless a
reporter is configured, and the CORS policy
(corsAllowMethods: [GET]) leaves it unreachable
from a cross-origin browser page.
Success is 202 Accepted, not
200: the report has been authenticated and
validated, but it is not yet an event — the next ingest tick
publishes it. Where an accepted report is held until then is per-stream
(mesh.repeater buffers in memory;
burn.line stages durably, because it reports once
a day and must survive a restart).
The response is
{reporterId, stream, accepted, warnings, receivedAt}.
A malformed entry is a warning, not a rejection — one repeater
with a typo'd id must not cost the operator the other eight — while a
malformed envelope rejects the whole request.
| Status | Meaning |
|---|---|
| 202 | Accepted. warnings[] lists entries that were skipped. Not yet published as an event — the next ingest tick does that. |
| 401 | Missing or unknown token. Deliberately carries no detail. |
| 403 | Valid token, but this reporter is not granted that stream. |
| 404 | Unknown stream. |
| 413 | Body over the configured limit. |
| 429 | Reporting faster than the reporter's configured minimum interval; see Retry-After. |
| 400 | Malformed JSON, unsupported schema_version, too many entries, nothing usable in the report, or a stream this deployment has not configured anything for. |
Stream mesh.repeater (schema_version 1)
POST /api/v1/ingest/mesh.repeater
Authorization: Bearer <token>
{
"schema_version": 1,
"generated_at": "2026-09-15T00:23:32+00:00",
"repeaters": [
{
"id": "de0715314cfa9b5e", // public key, or a prefix of one (≥8 hex)
"name": "SIERRA Arnold Summit",
"last_attempt": 1789431672, // unix seconds
"last_success": 1789431672, // null if never reached
"battery_voltage": 4.14, "battery_percent": 97.0,
"battery_percent_source": "estimated",
"temperature_c": 37.0, "humidity": null, "pressure": null,
"uptime_s": 2615083, "airtime_ms": 49646, "rx_airtime_ms": 211238,
"noise_floor_dbm": -116, "last_rssi_dbm": -88, "last_snr_db": 12.5,
"tx_queue_len": 0,
"nb_sent": 150305, "nb_recv": 649331,
"sent_flood": 149984, "sent_direct": 321,
"recv_flood": 642043, "recv_direct": 6702,
"direct_dups": 53, "flood_dups": 30331,
"full_evts": 0, "recv_errors": 240197,
"online": true
}
]
}
- A report is the reporter's COMPLETE current set. A node omitted from a report is treated as no longer monitored, the same contract a poller's result has with the disappearance sweep. Send every node you watch, every time.
- Unread values must be
null, never0. A null gauge is published as absent; a zero is published as a measurement. A node that has never been reached (last_success: null) is published with no telemetry block at all rather than a row of zeroed counters. - Timestamps are clamped to server time. A stamp in the future is treated as now.
- Reposting an identical report is harmless: entries are keyed by node, so a retry after a network failure overwrites rather than duplicates.
Stream mesh.packet (schema_version 1)
The raw over-the-air packets a MeshCore companion radio received, forwarded as-is. The Grid otherwise hears a node's advert — the signed packet carrying its name, role and position — through community MQTT bridges, which the SIERRA backbone reaches only when a distant gateway relays it (every few days per repeater, measured 2026-10); a companion in the area hears the same adverts daily. Each packet is the per-packet envelope the MQTT bridges publish, and it goes through the same decoder and the same Ed25519 signature check as an MQTT reception, so a forwarded packet can only say what the node itself signed.
POST /api/v1/ingest/mesh.packet
Authorization: Bearer <token>
{
"schema_version": 1,
"generated_at": "2026-10-09T05:00:00Z",
"observer": "e3635c65dcd443e7…", // the receiving radio's public key (optional)
"packets": [
{
"packet_type": 4, // ADVERT; other types are accepted and ignored
"raw": "11001148edae5567…", // the full frame as hex, or a meshcore:// link
"SNR": 8.5, "RSSI": -95, // optional, the radio's reading
"origin_id": "…", // optional, overrides observer for this packet
"timestamp": "2026-10-09T04:58:12Z" // optional; receive time is the Grid's own clock
}
]
}
acceptedcounts adverts applied, not packets received. A packet that could not be used is awarnings[]entry naming its index and why:malformed(not hex, or not a decodable advert frame) orrejected(the node's signature did not verify). Up to 25 are listed, then a count.- The rate limit is per stream. A reporter authorized for both streams can run a forwarder and a telemetry monitor as two processes on two cadences without one throttling the other.
- Nodes heard this way are ordinary mesh events — presence, position and
relay path exactly as from a bridge — with
provenance.attributionnaming the reporter (MeshCore community mesh via <reporter name>) the way it names a broker's operator.
Stream burn.line (schema_version 1)
POST /api/v1/ingest/burn.line
Authorization: Bearer <token>
{
"schema_version": 1,
"generated_at": "2026-09-18T14:05:00Z",
"readings": [
{
"line_id": "calaveras-apcd", // a configured grid.burn.lines id
"status": "orange", // green | red | orange
"message": "Burning allowed above 3500 feet.",
"cleaned_transcription": "permissive burn days at 3500 feet elevation or more",
"confidence": 88, // 0-100, the extraction model's own
"observed_at": "2026-09-18T14:02:11Z" // when the LINE WAS CALLED
}
]
}
- Send every line in ONE report. The endpoint rate-limits
per reporter against the last accepted report, so a request per
line would
429everything after the first. - A bad reading is a warning, not a rejection. Unknown
line_id, unrecognizedstatus, out-of-rangeconfidenceor a missingobserved_atskips that reading and names it in the 202'swarnings; the rest still land. The next attempt is tomorrow, so one malformed line must not discard the others. A report with nothing usable is a400. observed_atis when the line was CALLED, not when you posted. It is the freshness signal the burn status layer judges (grid.burn.burnDayStaleAfter, 36h), so a reading that cannot say when it was taken is rejected and a future one is clamped to now.- Unlike
mesh.repeater, an accepted reading is staged in the store rather than held in memory — a burn line is called once a day, so a restart must not cost the day's answer. It still writes no events; the scheduler remains the only writer of those.
How it surfaces
- One event per node, not one per input. A node reported by a
monitor and heard over MQTT resolves to the same event id — a reported prefix
is matched against the known node catalog. While a node is known only by
prefix its id is
meshcore:{prefix}; once an advert supplies the full key, the event id becomesmeshcore:{full key}and the prefix-keyed event is retired as superseded. Don't treat a mesh event id as permanent for a node not yet heard over MQTT. mesh.reachability—REACHABLE|UNREACHABLE. A node no monitor watches has neither, and must not render as one that is down: on/api/v1/eventsit readsMESH_REACHABILITY_UNSPECIFIED(the gateway emits unpopulated fields), and on the.geojsonlayer the property is omitted. It is derived from the age of the last success, not from the reporter'sonlineflag, so a single missed poll does not flip it. It is part of the event's content hash, so a transition appears in/api/v1/events/{id}/history.mesh.telemetry.admin— the last sample, withreporterIdandlastSuccessAt. It is excluded from the content hash, so frequent reporting refreshes these values without creating a revision per report: pollevents/{id}for current values, and expect/historyto show only meaningful changes.- A node known only to a monitor, that no advert has ever located, has
no geometry — a report carries no coordinates and the Grid
does not invent them — so it appears in
/api/v1/events?layer=meshand, where the operator has configured a place attachment, in place-scoped views, but itsmesh_nodemap feature has"geometry": nulluntil an advert supplies a position. Once one has, the position persists through the monitor's reports between adverts (or arrives sooner throughmesh.packet).