Wildfire Watch

API documentation

The JSON API behind every number on this site is public and free for reasonable use. Endpoints are read-only, base URL https://whatlabs.org/wildfire/api. Every response carries honest metadata: data mode (live or demonstration), config version and degraded-source notices. Attribution requirements for the underlying data are on the sources page.

All endpoints live under src/app/api/** (Next.js 15 App Router route handlers, Node.js runtime). Every response is JSON. Every GET read response carries a meta object (see "Meta" below) and the header Cache-Control: public, max-age=15, stale-while-revalidate=60. Every admin response carries Cache-Control: no-store.

Style note: no em dashes or en dashes anywhere in this document, code or API copy; commas, colons, "to" and hyphens only. UK English spelling.

Licensing note: public responses never name a per-detection satellite or instrument (which bird or sensor saw a given reading is operational detail, withheld ahead of licensing this product), and never give a platform name or exact clock time for a predicted future pass. Provider-level attribution (NASA FIRMS, EUMETSAT, Copernicus EFFIS, etc, see /sources) is unaffected and required by those providers' own terms. The admin API is unaffected: it is not reachable without a session and keeps every field.

Meta

interface ApiMeta {
  generatedAt: string;      // ISO timestamp
  dataMode: "live" | "demo" | "mixed" | "none";
  configVersion: string;    // config/risk-model.json "version"
  notices: string[];        // human-readable warnings, always safe to show
}

dataMode is computed from the is_demo flag on the rows that make up the response: none when the response is empty, live when every row is real, demo when every row is fixture data, mixed otherwise. Built by buildMeta() in src/lib/services/read/meta.ts.

notices includes, where relevant: degraded-source warnings (a source is "degraded" when it is enabled, configured, and either its last fetch failed or its last successful fetch is older than 2x its configured interval; an unconfigured adapter reporting fetch status skipped is not degraded, it is just not set up yet, see /api/sources/health), staleness warnings (fire danger grid falling back to an old computation) and result-cap warnings.

Caching

Two layers:

  • **Server-side memoisation** (src/lib/cache.ts, in-process TTL cache):

each endpoint's DB query is memoised for the TTL noted per endpoint below, keyed by the request's query parameters where relevant. This is what "Cache Ns" means in each section.

  • **HTTP Cache-Control**: a flat `public, max-age=15,

stale-while-revalidate=60 on every successful GET read, no-store` on every admin response and every 4xx/5xx. This is deliberately shorter than the server-side memoisation window; it just lets a CDN or browser dedupe bursts of requests without going stale relative to the server-side cache.

Auth

Admin endpoints require a session cookie, obtained from POST /api/admin/login. There is no src/middleware.ts guarding /api/admin/*: the edge runtime Next.js uses for middleware by default does not support node:crypto, which src/lib/auth.ts (HMAC session tokens) relies on. Forcing middleware onto the Node.js runtime is not reliably supported across Next.js deployment targets, so instead every admin route handler calls adminGuard(req) from src/lib/services/read/adminGuard.ts as its first line, returning a 401 { "error": "..." } when the session cookie is missing or invalid. Route handlers run in the Node.js runtime by default, so this works unmodified. The /admin pages (built by the UI workstream) use the companion isAdminSession() export from the same file, which reads the cookie via next/headers since a page component has no Request to read a header from.

GeoJSON conventions

Every spatial endpoint returns a FeatureCollection. Geometry is WGS84 (SRID 4326), longitude first. Point endpoints built from ST_X/ST_Y and polygon/multipolygon endpoints built from ST_AsGeoJSON(geom)::json.

---

GET /api/summary

Dashboard headline numbers.

**Params:** scope=uk|global (default uk). uk constrains the spatial stats to the UK bounding box; global computes them worldwide. highUrbanThreat and populationInConcernZones always cover the UK only (exposure data exists only for the UK); in global scope a notice says so.

**Response:** { meta: ApiMeta, stats: SummaryStats, degraded: SourceHealth[] } (SummaryStats, SourceHealth in src/lib/apiTypes.ts).

  • verifiedActive: verified incidents with status reported or active,

inner-joined to their cluster (a verified incident with no cluster is not counted).

  • probableClusters: possible, non-industrial clusters with medium/high

confidence, last observed within 48h, not yet attached to any verified incident.

  • majorIncidents: verified incidents with status active and

is_major = true.

  • highUrbanThreat: risk assessments with uti >= 60 for subjects that are

either a possible cluster or an active verified incident.

  • populationInConcernZones: sum of pop5km exposure for that same subject

set; null when none of them have an exposure row yet.

  • rawDetections: counts of raw_detection rows in the last 24h/48h/7d.
  • degraded: the subset of /api/sources/health that is currently

degraded (see Meta above).

**Caching:** 30s server-side. **Auth:** none. **Errors:** none beyond the generic 500.

curl -s http://localhost:3400/api/summary | jq

---

GET /api/incidents

One feature per distinct incident (never one satellite pixel): every verified incident (with its cluster's centroid) plus every unverified possible cluster.

World view mode: mode=top&limit=N (N capped at 1000, default 500) ignores bbox and returns the most significant incidents worldwide, ordered by major declaration, verified before unverified, urban threat, then peak fire radiative power. The response carries totalCount (the uncapped number of matching incidents) and a notice when the cap trimmed the list, so clients can state what is not shown.

**Params:**

  • period: 12h | 24h | 48h | 7d (default 24h).
  • bbox: west,south,east,north (WGS84). Omit for no spatial filter.
  • includeInactive: true | false (default false).

**Inclusion rules:**

  • Verified active / monitoring: always included, regardless of period.
  • Verified contained: included only when includeInactive=true (then not

further period-filtered).

  • Verified reported: included when last_observed_at >= now - period.
  • Verified extinguished: included only when includeInactive=true AND

last_observed_at >= now - period.

  • Unverified cluster possible: included when `last_observed_at >= now -

period`.

  • Unverified cluster false_positive / merged: included only when

includeInactive=true AND last_observed_at >= now - period.

  • Cluster verified status is never listed directly; the corresponding

verified-incident feature represents it.

**Response:** FeatureCollection<Point, IncidentFeatureProps> plus meta. IncidentFeatureProps (src/lib/apiTypes.ts) is extended in the response with two numeric convenience fields not in the shared type: firstObservedAtMs, lastObservedAtMs (epoch milliseconds, for client-side filtering without re-parsing ISO strings).

hasDownwindConcern is true when risk_assessment.downwind->>'computed' = 'true' and at least one entry in downwind.settlements has downwindNow: true.

**Caching:** 30s server-side, keyed by period:bbox:includeInactive. **Auth:** none.

curl -s "http://localhost:3400/api/incidents?period=48h&includeInactive=false" | jq '.features | length'

---

GET /api/incidents/[id]?kind=cluster|verified

Full detail for one incident. kind=cluster resolves id against incident_cluster; kind=verified resolves id against verified_incident and then follows cluster_id to load the same cluster data (a verified incident with a null cluster_id still returns, with cluster: null and empty detections/frpTrend/sources).

**Response:** IncidentDetail (defined in src/lib/services/read/incidentDetail.ts; not in apiTypes.ts, which only pins the list-view shape). Shape:

{
  cluster: ClusterDetail | null;
  verified: VerifiedDetail | null;
  detections: DetectionDetail[];       // ordered by observedAt ascending
  weather: {
    latest: WeatherSample | null;      // nearest observation within 25km
    forecast: WeatherSample[];         // next 24h, nearest point within 25km
    rainfall: { d7: number|null; d14: number|null; d30: number|null };
  };
  exposure: ExposureInfo | null;
  risk: RiskInfo | null;
  officialUpdates: OfficialUpdateDetail[];   // newest first
  statusHistory: StatusHistoryEntry[];       // cluster and incident entries, chronological
  qualityFlags: QualityFlag[];               // unresolved only
  areaEstimates: AreaEstimate[];             // verified.areaHa + intersecting burned_area polygons
  frpTrend: { observedAt: string; maxFrp: number|null }[]; // 10-minute buckets
  confidenceReasons: string[];
  sources: { key, name, kind, latestFetchAt, maxObservedAt }[];
  meta: ApiMeta;
}

risk/exposure are read for the subject matching the requested kind (cluster subject for kind=cluster, incident subject for kind=verified), falling back to the underlying cluster's assessment when the incident has not been separately assessed yet.

areaEstimates combines the verified incident's own area_ha/area_source with any burned_area polygon (source EFFIS satellite burnt area mapping) that intersects the cluster's extent, so disagreeing figures sit side by side deliberately; this is defensive against burned_area not existing yet or having an unexpected shape (degrades to just the verified figure, no 500).

**Caching:** not memoised server-side (single-record lookups); relies on the flat HTTP Cache-Control header only. **Auth:** none. **Errors:** 404 { "error": "..." } for an unknown id/kind combination.

curl -s "http://localhost:3400/api/incidents/8?kind=cluster" | jq '.cluster, .risk.uti'

---

GET /api/detections

Raw satellite detections as points.

**Params:**

  • period: 12h | 24h | 48h | 7d (default 24h). Ignored when clusterId is given.
  • bbox: west,south,east,north. Ignored when clusterId is given.
  • clusterId: when given, returns only that cluster's detections (all of

them, subject to the same 5000 cap).

**Response:** FeatureCollection<Point, DetectionFeatureProps> plus meta. DetectionFeatureProps is extended with observedAtMs (epoch ms). Ordered newest first. Hard-capped at 5000 features; when the underlying result exceeds the cap, meta.notices includes a cap warning and the response is truncated (not an error).

No per-detection satellite or instrument identity is returned (this is a public endpoint; see the licensing note at the top of this file). Each feature instead carries corroborationSensors/corroborationPasses: the count of distinct platforms and distinct 10-minute observation passes behind the owning cluster (1/1 for a detection with no cluster).

**Caching:** 30s server-side, keyed by period:bbox or cluster:<id>. **Auth:** none.

curl -s "http://localhost:3400/api/detections?period=24h" | jq '.meta'

Excludes geostationary rows: they have their own endpoint below, since they need a different reduction (latest per pixel, not every stored timeslot).

---

GET /api/geo-detections

Geostationary (LSA SAF MTG FRP-Pixel / MSG FRP-PIXEL) heat detections, one feature per pixel: the LATEST stored timeslot only, not the full history (the ingest adapter keeps every 10/15 minute reading of the same pixel, see src/lib/ingest/adapters/lsasaf.ts; this endpoint collapses that down for the map so the same fire does not draw as 144 stacked footprints a day). "Latest per pixel" is approximated by rounding each row's lat/lon to 2 decimal places and taking the newest row per rounded cell.

**Params:**

  • period: 12h | 24h | 48h | 7d (default 24h): how far back a

pixel's last reading may be and still count as "current".

  • bbox: west,south,east,north.

**Response:** FeatureCollection<Point, GeoDetectionFeatureProps> plus meta (wildfire edition only; the conflict edition's branch matches /api/detections's own conflict branch and omits meta). GeoDetectionFeatureProps (src/lib/apiTypes.ts): id, observedAt, frpMw, scanKm, trackKm, dayNight (present where the underlying feed sets it; currently always null here), confirmation (provisional | confirmed | unconfirmed, see src/lib/geostationary/confirm.ts), confirmedById, corroborationSensors, corroborationPasses, isDemo. No per-pixel satellite or instrument identity (see the licensing note at the top of this file); corroborationSensors/corroborationPasses are derived straight from confirmation (2/2 once a sharper pass has confirmed it, 1/1 otherwise).

**Caching:** 30s server-side, keyed by period:bbox. **Auth:** none.

curl -s "http://localhost:3400/api/geo-detections?period=24h" | jq '.features[0].properties'

---

GET /api/risk/cells

Latest fire danger grid.

**Params:** none.

**Response:** FeatureCollection<Polygon, { band, score, validAt, components }> plus meta. Latest fire_danger_cell row per cell_key with valid_at within the last 12 hours; if the grid has not run recently enough for any cell to qualify, falls back to the most recent computation regardless of age and adds a staleness notice to meta.notices rather than returning an empty collection.

**Caching:** 120s server-side. **Auth:** none.

curl -s http://localhost:3400/api/risk/cells | jq '.features | length'

---

GET /api/weather/grid

National weather grid snapshot (one point per grid cell). Each feature also carries rainNext24Mm (forecast precipitation for the next 24 hours, mm; null until the ingest cycle that computes it has run) which the map's UK rain-forecast layer renders.

**Params:** none.

GET /api/weather/radar-meta

Proxy for the RainViewer public weather-maps index, cached 60 seconds. Returns { available, latestTs, host, framePath } for the newest composite radar frame; available: false when the upstream is unreachable, which the map's layer panel surfaces as "radar unavailable". Radar tiles themselves are fetched by the browser directly from RainViewer (attributed on the map).

**Response:** FeatureCollection<Point, { windMs, windDirDeg, gustMs, tempC, rhPct, observedAt }> plus meta. Latest weather_observation per payload->>'cellKey' where payload->>'grid' = 'national'.

**Caching:** 120s server-side. **Auth:** none.

curl -s http://localhost:3400/api/weather/grid | jq '.features[0]'

---

GET /api/weather?lat&lon

Point weather: nearest observation, 24h forecast, rainfall accumulations.

**Params:** lat (49 to 61.5), lon (-9 to 2.5), both required.

**Response:** { latest: WeatherSample|null, forecast: WeatherSample[], rainfall: {d7,d14,d30}, meta }.

**Caching:** 60s server-side, keyed by lat/lon rounded to 3 decimal places (roughly 100m) to bound cache cardinality; not specified in the original brief's per-endpoint cache list, chosen to match the cadence of nearby endpoints. **Auth:** none. **Errors:** 400 when lat/lon are missing or out of range.

curl -s "http://localhost:3400/api/weather?lat=51.5&lon=-0.1" | jq '.latest'

---

GET /api/history/series?keys=

Historical trend series (FRS incident counts, workforce, etc).

**Params:** keys: comma-separated series_key list, or all to list every distinct series in historical_metric.

**Response:** { series: HistorySeries[], meta }. A requested key with no matching rows still returns an entry (points: []) and a notice in meta.notices, rather than silently omitting it. HistorySeries.label (the series display name) has no dedicated column in historical_metric (label there is the per-point x-axis label); it is derived by humanising the series_key (underscores to spaces, first letter capitalised). Fixture authors: a friendlier display name would need a new column, this is a reasonable placeholder in the meantime.

**Caching:** 10 minutes server-side, keyed by the raw keys param. **Auth:** none. **Errors:** 400 when keys is missing or empty.

curl -s "http://localhost:3400/api/history/series?keys=all" | jq '.series[].key'

---

GET /api/sources/health

Every data source's health, including adapters that have never run at all.

**Params:** none.

**Response:** { sources: SourceHealth[], meta }. Combines every source DB row (health rolled up from source_fetch) with a placeholder entry for any adapter in src/lib/ingest/registry.ts that has no source row yet (never run). configured/setupHint are read from the matching adapter's own configured()/setupHint when the registry has one (the documented source.config->>'configured' fallback is effectively unused in practice, since runAdapter never persists those keys to source.config; it remains the fallback for a source row with no matching adapter). stale uses the same 2x-configured-interval rule as the summary's degraded computation.

**Caching:** 30s server-side. **Auth:** none. (Health is operational status, not sensitive; the admin review queue at /api/admin/clusters is the guarded surface.)

curl -s http://localhost:3400/api/sources/health | jq '.sources[] | {key, configured, stale, lastStatus}'

---

GET /api/threat/sectors

Downwind concern sector polygons, for the map's wind-threat overlay.

**Params:** none.

**Response:** FeatureCollection<Polygon, { subjectType, subjectId, uti, bearingDeg, lengthKm, windMs, isDemo }> plus meta. One feature per risk_assessment row whose downwind jsonb has computed: true and a sector polygon. This is a new contract addition beyond the original docs/CONTRACTS.md API shapes list, agreed for this build; the sector itself is explicitly not a fire spread prediction (see src/lib/geo.ts's sectorPolygon doc comment).

**Caching:** 60s server-side (not specified in the original brief; chosen between the 30s incident cadence and 120s risk-grid cadence since this is risk-derived but changes less often than raw detections). **Auth:** none.

curl -s http://localhost:3400/api/threat/sectors | jq '.features | length'

---

Context layer endpoints

New contract additions (map background layers), all FeatureCollection plus meta, all cached **1 hour** server-side, all unauthenticated, all accept an optional bbox=west,south,east,north filter.

GET /api/context/settlements

Point, props { id, name, population, kind, country }. When no bbox is given, restricted to population >= 3000 (national default view); a bbox lifts that restriction since the box itself already bounds the result. Capped at 10000 features (notice on cap).

curl -s http://localhost:3400/api/context/settlements | jq '.features | length'

GET /api/context/facilities

Point, props { kind, name }, from infrastructure_feature. Capped at 10000 features (notice on cap).

curl -s "http://localhost:3400/api/context/facilities?bbox=-3.5,51,-2.5,52" | jq '.features[0]'

GET /api/context/fra

Fire and Rescue Authority boundaries, MultiPolygon/Polygon, props { name, areaCode, country }, geometry simplified with ST_SimplifyPreserveTopology(geom, 0.002) to keep the payload light enough for a national overview map.

curl -s http://localhost:3400/api/context/fra | jq '.features[0].properties'

GET /api/context/burned

Burnt area polygons from burned_area (populated by the EFFIS adapter), props { areaHa, startDate, endDate, isDemo }. burned_area is created by db/migrations/0003_effis.sql, owned by the ingest workstream; this endpoint checks information_schema first and returns an empty collection with a notice (rather than a 500) if the table is missing, and also tolerates the table existing with an unexpected column shape the same way.

curl -s http://localhost:3400/api/context/burned | jq '.meta'

---

GET /api/search?q=

Combined postcode, settlement and incident search for the map's search box.

**Params:** q, minimum 2 characters (400 if shorter).

**Response:** { results: SearchResult[], meta } where SearchResult is a tagged union:

{ type: "incident"; name: string; id: number; kind: "cluster"|"verified"; lat: number; lon: number }
{ type: "postcode"; name: string; lat: number; lon: number }
{ type: "settlement"; name: string; lat: number; lon: number; population: number|null }

Result order: incidents first, then the postcode match (if any), then settlements.

  • **Incidents**: verified_incident.name/slug and

incident_cluster.public_code, ILIKE '%q%', limit 5.

  • **Postcode**: proxied server-side to https://api.postcodes.io/postcodes/<q>

when q looks like a full UK postcode (^[A-Za-z]{1,2}\d[A-Za-z\d]?\s*\d[A-Za-z]{2}$) or a partial one (5+ characters starting with letters then a digit). 5 second timeout; any failure (timeout, non-200, network error) is swallowed and simply omits the postcode result rather than failing the whole search. Cached 24 hours per postcode string.

  • **Settlements**: settlement.name prefix or contains match, limit 6,

prefix matches ranked first.

**Caching:** 60s server-side per q (case-insensitive). **Auth:** none. **Errors:** 400 when q is shorter than 2 characters.

curl -s "http://localhost:3400/api/search?q=Swin" | jq '.results'

---

Alerts API

Email alert subscriptions ("wildfire alerts near me"). Implementation: src/lib/alerts/{tokens,mailer,run}.ts, db/migrations/0004_alerts.sql, db/migrations/0005_alert_wind.sql. Every endpoint is unauthenticated and public; Cache-Control: no-store on every response (nothing here is safe to cache). Confirm and unsubscribe links are stateless signed tokens (src/lib/alerts/tokens.ts, HMAC over purpose:subscriptionId:salt, keyed by ALERTS_SECRET or, if unset, ADMIN_TOKEN); a subscription is never mailed, listed or altered without a token that verifies against that row's own salt.

POST /api/alerts/subscribe

Double opt-in signup. Body:

{
  email: string;                 // valid email, max 254 chars
  lat: number;                   // -85..85
  lon: number;                   // -180..180
  placeLabel?: string;           // max 80 chars, shown in emails, e.g. "Ambleside"
  radiusKm: 10 | 25 | 50;
  minLevel?: "verified_only" | "verified_or_high"; // default "verified_or_high"
  windFilter?: "any" | "toward_me_only";           // default "any"
  acceptTerms: true;             // must be exactly true
}

windFilter: "toward_me_only" restricts alerts to events the current wind is carrying toward the subscriber's location (see isWindTowardSubscriber in src/lib/alerts/run.ts); a formally declared major incident always alerts regardless of this setting, and an event with no recent nearby wind observation is kept rather than silently dropped (fail open, never fail silent).

An existing active subscription for the same address, radius and a location within about 2km is not duplicated. Response is always { ok: true, state } on success, where state is one of:

  • "confirmation_sent": a new confirmation email was sent.
  • "confirmation_pending": an unconfirmed signup for this address/location/

radius already exists and was created less than 10 minutes ago; not re-mailed.

  • "already_subscribed": a confirmed, active subscription for this address/

location/radius already exists.

No response ever claims a subscription is active before its confirmation link has been clicked.

**Rate limit:** 5 requests per 15 minutes per IP (x-forwarded-for, first value), 429 { error } when exceeded.

**Errors:**

  • 503 { error }: MAILTRAP_TOKEN or ALERTS_FROM is not configured on

this deployment; nothing is written.

  • 400 { error, issues }: body fails validation (bad email, missing

acceptTerms, bad radiusKm, etc).

  • 413 { error }: body over 4096 bytes.
  • 502 { error }: the confirmation email could not be sent; the row is

still created (so a retry via confirmation_pending can resend) but nothing is confirmed.

curl -s -X POST http://localhost:3400/api/alerts/subscribe \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.org","lat":54.4,"lon":-3.1,"placeLabel":"Ambleside","radiusKm":25,"minLevel":"verified_or_high","windFilter":"toward_me_only","acceptTerms":true}'

GET /api/alerts/confirm?t=

Verifies the confirm-purpose token, sets confirmed_at (first time only), and redirects (302) to ${PUBLIC_ORIGIN}${BASE_PATH}/?alerts=confirmed. An invalid, forged or already-superseded token returns 400 with a plain text explanation; nothing is confirmed.

GET /api/alerts/unsubscribe?t= and POST /api/alerts/unsubscribe

Verifies the unsub-purpose token and sets unsubscribed_at (idempotent). 200 with a plain text confirmation on success, 400 plain text for an invalid or forged token. Both methods behave identically; POST exists for RFC 8058 one-click unsubscribe (List-Unsubscribe-Post: List-Unsubscribe= One-Click), which mail clients call automatically without opening a browser.

curl -s "http://localhost:3400/api/alerts/unsubscribe?t=42.<signature>"

Digest delivery (not an HTTP endpoint)

runAlerts(sql) in src/lib/alerts/run.ts is called by the jobs scheduler (scripts/jobs.ts) after every tick, wrapped so a failure here never stops ingestion. For every confirmed, non-unsubscribed subscription whose last_notified_at is null or more than 6 hours ago, it collects qualifying non-demo events within the subscriber's radius since their last notification (or since confirmation, for the first email), applies the windFilter, and sends at most one digest email (max 6 events, newest and major incidents first). Returns { checked, sent, skipped }; skipped is "mailer not configured" or "already running" (a Postgres advisory lock prevents overlapping runs), otherwise null.

Wildfire Watch is a decision-support and public-information tool. It is not an official warning service.

Local fire and rescue services remain authoritative: in an emergency call 999. The government publishes official guidance on what to do about wildfires.

Data is drawn from NASA FIRMS, Open-Meteo, Copernicus EFFIS, OpenStreetMap contributors, ONS and OS boundaries, MHCLG fire statistics and other named sources, each shown with its own licence and attribution.

Wildfire Watch is provided by WhatLabs. Contact: hello@whatlabs.org