PolarForecast
Public API ยท v1

Polar Forecast API

Realtime and hourly aurora forecasts, plain-JSON, for the Polar Forecast web app and mobile app to build on. Every field below is the exact, current response shape โ€” nothing summarized.

Base URL https://api.polarforecast.com
Fallback polar-forecast-api.pf-engine.workers.dev
Conventions

Overview

The API is JSON over HTTPS. There's no envelope beyond what's documented per endpoint, no XML, no versioning header โ€” the version lives in the path (/v1/โ€ฆ) and is echoed back as apiVersion: "v1" on every response.

Content-Type: application/json ISO 8601 timestamps, always UTC Coordinates as decimal degrees

Every successful and error response carries an engine.version or requestId for traceability โ€” include the X-Request-ID response header when reporting an issue.


Access

Authentication

There is no API key. Every endpoint below is open and unauthenticated today.

Send an optional X-Polar-Client header โ€” one of web, ios, hotel, widget โ€” so requests are attributable in logs. It's metadata only: never checked, never required, never affects rate limits.


Limits

Rate limits

Limits are per client IP, per route. Exceeding one returns 429 with a Retry-After header โ€” back off and retry, don't hammer.

RouteLimit
GET /v1/forecast60 requests / 60s
GET /v1/forecast/hourly30 requests / 60s
GET /v1/forecast/summary30 requests / 60s
GET /health120 requests / 60s
POST /v1/reports/session & /v1/reports/{id}10 requests / 60s (shared)
429 Too Many Requests
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Please try again shortly.",
    "requestId": "โ€ฆ"
  }
}

Retry-After: 60 is set on the response headers.


Browsers

CORS

Locked to an allowlist โ€” there's no wildcard Access-Control-Allow-Origin: *. Server-to-server calls (curl, native mobile) aren't affected; this only matters inside a browser.

https://polarforecast.com https://www.polarforecast.com https://lab.polarforecast.com http://localhost:3000

GET routes allow GET, OPTIONS; the two report routes allow POST, OPTIONS. Allowed request header: Content-Type, X-Polar-Client.


Freshness

Caching

Cache-Control applies only to 200 responses โ€” every error, on every route, is no-store.

RouteCache-Control (on 200)
/v1/forecastpublic, max-age=30, s-maxage=30
/v1/forecast/hourlypublic, max-age=60, s-maxage=60
/v1/forecast/summarypublic, max-age=60, s-maxage=60
/health, /v1/reports/*no-store

Reference

Errors

Every error follows the same envelope, regardless of endpoint:

Error shape
{
  "error": {
    "code": "INVALID_LOCATION",
    "message": "Latitude must be between -90 and 90.",
    "requestId": "c7f1โ€ฆ"
  }
}
CodeStatusMeaning
INVALID_LOCATION400Missing/out-of-range lat or lon
INVALID_TIMESTAMP400Unparsable at
INVALID_PARAMETER400e.g. hours outside 1โ€“12
INVALID_JSON400Body isn't valid/parseable JSON
NOT_FOUND404Unknown route
REPORT_SESSION_NOT_FOUND404Unknown/invalid report session id
METHOD_NOT_ALLOWED405Wrong HTTP method for a known path
OBSERVATION_ALREADY_SUBMITTED409That session already has an observation
REPORT_SESSION_EXPIRED410Session is older than 6h (checked before 409)
PAYLOAD_TOO_LARGE413Body over 4096 bytes
UNSUPPORTED_MEDIA_TYPE415Content-Type isn't application/json
RATE_LIMITED429Too many requests โ€” see Rate limits
INTERNAL_ERROR500Unexpected server-side failure
FORECAST_UNAVAILABLE503No reasonable basis for a forecast right now

Individual data-provider hiccups (e.g. one weather source timing out) don't trigger FORECAST_UNAVAILABLE โ€” the engine degrades gracefully and still returns 200 with lower dataQuality/confidence instead.


Forecast

Realtime forecast

The current Polar Score for one location, right now. This is the endpoint behind the app's main aurora meter.

GET /v1/forecast

Query parameters

ParamTypeDescription
latnumberrequiredLatitude, โˆ’90 to 90
lonnumberrequiredLongitude, โˆ’180 to 180
atISO 8601optionalDefaults to now

Example request

curl
curl "https://api.polarforecast.com/v1/forecast?lat=69.6492&lon=18.9553"

Response 200

application/json
{
  "apiVersion": "v1",
  "location": { "latitude": 69.6492, "longitude": 18.9553 },
  "generatedAt": "2026-08-26T20:15:00.000Z",
  "forecast": {
    "score": 42,              // 0-100, or null with no basis
    "auroraPotential": 55,   // 0-100, or null
    "activity": 48,
    "auroraLocal": 52,
    "visibility": 78,
    "darkness": 100,
    "trend": "improving",
    "confidence": 81
  },
  "dataQuality": { "spaceWeather": 100, "weather": 90, "ovation": 100, "history": 100, "overall": 96 },
  "availability": { "activity": true, "auroraLocal": true, "visibility": true, "darkness": true, "trend": true },
  "explanation": {
    "summary": "Moderate aurora activity with good local visibility.",
    "reasons": [
      { "code": "BZ_SOUTHWARD", "type": "positive", "message": "Bz has turned southward, favoring aurora activity." }
    ]
  },
  "engine": { "version": "1.0.0" }
}

trend is one of rapidly_improving | improving | stable | worsening | rapidly_worsening. lightPollution (0โ€“100) appears only near the 9 reference locations this dataset currently covers โ€” absent everywhere else, never a guess.

Hourly timeline

A forward-looking timeline plus the single best window to go look up, for the "tonight" view.

GET /v1/forecast/hourly

Query parameters

ParamTypeDescription
latnumberrequiredLatitude, โˆ’90 to 90
lonnumberrequiredLongitude, โˆ’180 to 180
hoursintegeroptional1โ€“12, default 12

Response 200 (points truncated for length)

application/json
{
  "apiVersion": "v1",
  "location": { "latitude": 69.6492, "longitude": 18.9553 },
  "generatedAt": "2026-08-26T20:15:00.000Z",
  "horizonHours": 12,
  "bestViewingWindow": {
    "start": "2026-08-26T21:30:00.000Z",
    "end": "2026-08-27T00:00:00.000Z",
    "peakTimestamp": "2026-08-26T22:45:00.000Z",
    "peakScore": 67,
    "confidence": 74,
    "quality": "good",
    "summary": "Good viewing window forming late tonight."
  },
  "points": [
    {
      "timestamp": "2026-08-26T21:00:00.000Z",
      "leadMinutes": 45,
      "forecastMode": "nowcast",
      "score": 58, "auroraPotential": 63, "activity": 55, "auroraLocal": 60,
      "visibility": 70, "darkness": 100, "trend": "improving", "confidence": 78,
      "dataQuality": { โ€ฆ }, "availability": { โ€ฆ }, "explanation": { โ€ฆ }
    },
    // โ€ฆ one point per hour, up to `horizonHours`
  ],
  "engine": { "version": "1.0.0" }
}

bestViewingWindow is null when there's no useful window โ€” never fabricated. forecastMode tells you how the point was produced: realtime (โ‰ค30 min out), nowcast (โ‰ค2h), short_range (โ‰ค6h), else forecast โ€” useful for deciding how much to trust a far-out point in the UI.

Summary

A product-ready, plain-language summary โ€” headline, status, and whether the user should be offered the "report what you saw" flow right now. Built for a home-screen card, not a dashboard.

GET /v1/forecast/summary

Query parameters

Same as Hourly timeline โ€” lat, lon required, hours optional (1โ€“12, default 12, used to build tonight).

Response 200

application/json
{
  "apiVersion": "v1",
  "location": { "latitude": 69.6492, "longitude": 18.9553 },
  "generatedAt": "2026-08-26T20:15:00.000Z",
  "status": "good",
  "headline": "Good chances tonight",
  "summary": "Clearing skies and rising activity favor a good show after 22:00.",
  "current": { "score": 42, "auroraPotential": 55, "confidence": 81, "activity": 48, "auroraLocal": 52, "visibility": 78, "darkness": 100, "trend": "improving" },
  "tonight": {
    "peakScore": 67, "peakTimestamp": "2026-08-26T22:45:00.000Z", "confidence": 74,
    "bestViewingWindow": { โ€ฆ } // same shape as the hourly endpoint, or null
  },
  "conditions": { "aurora": "good", "clouds": "fair", "darkness": "excellent", "trend": "improving" },
  "reasons": [ { "code": "BZ_SOUTHWARD", "type": "positive", "message": "โ€ฆ" } ],
  "dataQuality": { "current": { โ€ฆ }, "horizon": { "average": 88, "minimum": 70 } },
  "reporting": {
    "eligible": true,
    "reason": "eligible",
    "recommendedDelayMinutes": 30,
    "sessionMaxAgeHours": 6,
    "maxNotesLength": 500
  },
  "engine": { "version": "1.0.0" }
}

status is one of unavailable | very_poor | poor | fair | good | very_good | excellent. reporting.reason explains an ineligible state (daylight, insufficient_forecast_data, etc.) โ€” not a session id or token, purely advisory copy for the UI.


Reports

Create a report session

Step 1 of "what did you actually see": the server snapshots its own forecast for a location โ€” the client never supplies a forecast result, only where the observer is standing.

POST /v1/reports/session

Request body

FieldTypeDescription
latitudenumberrequiredโˆ’90 to 90
longitudenumberrequiredโˆ’180 to 180

Response 201

application/json
{
  "apiVersion": "v1",
  "reportSession": {
    "id": "5b1e2b8e-1c2e-4c9a-9b3e-2f6a2e9b3a11",
    "createdAt": "2026-08-26T20:15:00.000Z",
    "forecastTimestamp": "2026-08-26T20:15:00.000Z",
    "expiresAt": "2026-08-27T02:15:00.000Z",
    "recommendedReportAt": "2026-08-26T20:45:00.000Z"
  }
}

Hold on to id โ€” it's what you submit the observation against. Sessions expire 6h after forecastTimestamp; the UI should nudge the user around recommendedReportAt (+30 min), once conditions have had time to actually develop.

Submit an observation

Step 2: what the user actually saw, tied to a session from the step above.

POST /v1/reports/{id}

{id} is the reportSession.id from the step above.

Request body

FieldTypeDescription
levelstringrequirednone | weak | moderate | strong | exceptional
observedAtISO 8601requiredโ‰ค5 min in the future, within 6h of the session's forecast
notesstringoptionalMax 500 characters

Response 201

application/json
{
  "apiVersion": "v1",
  "report": {
    "accepted": true,
    "reportSessionId": "5b1e2b8e-1c2e-4c9a-9b3e-2f6a2e9b3a11",
    "level": "moderate",
    "observedAt": "2026-08-26T20:48:00.000Z"
  }
}

One observation per session โ€” a second submit returns 409 OBSERVATION_ALREADY_SUBMITTED. Location always comes from the session's original coordinates; it can't be overridden per-observation.


System

Health

Backend/collector status โ€” poll this for a status page, not for anything user-facing.

GET /health

Response 200 (503 only when status: "unhealthy")

application/json
{
  "status": "healthy", // "healthy" | "degraded" | "unhealthy"
  "timestamp": "2026-08-26T20:15:00.000Z",
  "collectors": {
    "spaceWeather": { "status": "healthy", "lastSuccessAt": "โ€ฆ", "ageMinutes": 1, "consecutiveFailures": 0, "running": true },
    "ovation": { โ€ฆ }
  },
  "data": { "spaceWeatherAgeMinutes": 1, "ovationAgeMinutes": 3, "spaceWeatherHistoryPoints": 1440, "ovationPoints": 288 },
  "weatherCache": { "entries": 42 },
  "engine": { "version": "1.0.0" }
}

degraded still returns 200 โ€” the API is operative, just running on stale-ish data. Only unhealthy returns 503.

Polar Forecast API ยท v1 Generated from the live route/handler source โ€” not hand-maintained prose.