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.
https://api.polarforecast.com
polar-forecast-api.pf-engine.workers.dev
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.
Every successful and error response carries an engine.version or requestId for traceability โ include the X-Request-ID response header when reporting an issue.
Authentication
There is no API key. Every endpoint below is open and unauthenticated today.
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.
| Route | Limit |
|---|---|
| GET /v1/forecast | 60 requests / 60s |
| GET /v1/forecast/hourly | 30 requests / 60s |
| GET /v1/forecast/summary | 30 requests / 60s |
| GET /health | 120 requests / 60s |
| POST /v1/reports/session & /v1/reports/{id} | 10 requests / 60s (shared) |
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please try again shortly.",
"requestId": "โฆ"
}
}
Retry-After: 60 is set on the response headers.
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.
GET routes allow GET, OPTIONS; the two report routes allow POST, OPTIONS. Allowed request header: Content-Type, X-Polar-Client.
Caching
Cache-Control applies only to 200 responses โ every error, on every route, is no-store.
| Route | Cache-Control (on 200) |
|---|---|
| /v1/forecast | public, max-age=30, s-maxage=30 |
| /v1/forecast/hourly | public, max-age=60, s-maxage=60 |
| /v1/forecast/summary | public, max-age=60, s-maxage=60 |
| /health, /v1/reports/* | no-store |
Errors
Every error follows the same envelope, regardless of endpoint:
{
"error": {
"code": "INVALID_LOCATION",
"message": "Latitude must be between -90 and 90.",
"requestId": "c7f1โฆ"
}
}
| Code | Status | Meaning |
|---|---|---|
| INVALID_LOCATION | 400 | Missing/out-of-range lat or lon |
| INVALID_TIMESTAMP | 400 | Unparsable at |
| INVALID_PARAMETER | 400 | e.g. hours outside 1โ12 |
| INVALID_JSON | 400 | Body isn't valid/parseable JSON |
| NOT_FOUND | 404 | Unknown route |
| REPORT_SESSION_NOT_FOUND | 404 | Unknown/invalid report session id |
| METHOD_NOT_ALLOWED | 405 | Wrong HTTP method for a known path |
| OBSERVATION_ALREADY_SUBMITTED | 409 | That session already has an observation |
| REPORT_SESSION_EXPIRED | 410 | Session is older than 6h (checked before 409) |
| PAYLOAD_TOO_LARGE | 413 | Body over 4096 bytes |
| UNSUPPORTED_MEDIA_TYPE | 415 | Content-Type isn't application/json |
| RATE_LIMITED | 429 | Too many requests โ see Rate limits |
| INTERNAL_ERROR | 500 | Unexpected server-side failure |
| FORECAST_UNAVAILABLE | 503 | No 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.
Realtime forecast
The current Polar Score for one location, right now. This is the endpoint behind the app's main aurora meter.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| lat | number | required | Latitude, โ90 to 90 |
| lon | number | required | Longitude, โ180 to 180 |
| at | ISO 8601 | optional | Defaults to now |
Example request
curl "https://api.polarforecast.com/v1/forecast?lat=69.6492&lon=18.9553"
Response 200
{
"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.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| lat | number | required | Latitude, โ90 to 90 |
| lon | number | required | Longitude, โ180 to 180 |
| hours | integer | optional | 1โ12, default 12 |
Response 200 (points truncated for length)
{
"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.
Query parameters
Same as Hourly timeline โ lat, lon required, hours optional (1โ12, default 12, used to build tonight).
Response 200
{
"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.
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.
Request body
| Field | Type | Description | |
|---|---|---|---|
| latitude | number | required | โ90 to 90 |
| longitude | number | required | โ180 to 180 |
Response 201
{
"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.
{id} is the reportSession.id from the step above.
Request body
| Field | Type | Description | |
|---|---|---|---|
| level | string | required | none | weak | moderate | strong | exceptional |
| observedAt | ISO 8601 | required | โค5 min in the future, within 6h of the session's forecast |
| notes | string | optional | Max 500 characters |
Response 201
{
"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.
Health
Backend/collector status โ poll this for a status page, not for anything user-facing.
Response 200 (503 only when status: "unhealthy")
{
"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.