Heavens API
Global weather for any coordinate on Earth - land, sea, cities, mountains - out to 10 days. Served from Mareel's own pre-baked population-tiered grid: ~2 km cells over cities, ~4 km over towns and coasts, ~11 km everywhere else (10 million cells, no gaps). A region-aware blend of the best open models: convection-permitting regional models (2.2-6.5 km) win where they cover; the ECMWF + GFS global backbone carries the full horizon everywhere. Heavens is the atmosphere; the sea state is the separate Waters product.
Endpoint
GET /v1/heavens
| Param | Type | Default | Notes |
|---|---|---|---|
lat, lon | number | required | -90..90 / -180..180 |
vars | comma list | all | subset of the variable keys below |
The response resolves your coordinate to the nearest grid cell (resolved.nearest_cell_km tells you the snap distance - inside a city that is typically ~1 km). The grid covers land and sea alike, so virtually every coordinate resolves; a point more than 60 km from any cell returns 422 no_cell.
Response
{
"query": { "lat": 59.27, "lon": 15.21 },
"resolved": { "nearest_cell_km": 0.9, "cell": [59.26, 15.20],
"grid_cycle": "2026-07-15T00:00:00+00:00", "steps": 93 },
"variables": ["temperature_2m", "wind_speed", "cloud_total", "precipitation", ...],
"steps": [
{ "time": "2026-07-15T00:00:00+00:00",
"values": { "temperature_2m": 13.8, "dewpoint_2m": 11.2, "relative_humidity_2m": 84.0,
"wind_speed": 6.1, "wind_gust": 11.0, "wind_direction": 232.0,
"cloud_total": 55.0, "precipitation": 0.0, "precip_type": 0,
"cape": 120.0, "mslp": 1013.2, "visibility": 24.1, ... } },
...
],
"meta": { "model": "pre-baked region-aware grid", "cache": "static-grid",
"took_ms": 1.6, "disclaimers": ["Not for navigation or safety-of-life decisions; ..."] }
}A variable a source cannot reach at a given step is null - the series ends honestly where the underlying model runs out rather than padding stale values. cloud_ceiling is null under a clear sky (no cloud base to report), and snow variables are null where snow is not modelled (tropical ocean).
Variables
| Key | Unit | Convention | Primary sources |
|---|---|---|---|
temperature_2m | °C | 2 m air temperature | ECMWF IFS · GFS · ICON-EU/D2 · HRRR regionally |
dewpoint_2m | °C | 2 m dewpoint | same as temperature |
relative_humidity_2m | % | 2 m relative humidity | same as temperature |
wind_speed / wind_gust | knots | 10 m sustained / gust | ECMWF IFS · GFS · ICON-EU/D2 · HRRR regionally |
wind_direction | deg | coming from | same as wind |
cloud_total | % | total cloud cover | ECMWF IFS · GFS · ICON · HRRR |
cloud_low / cloud_mid / cloud_high | % | layer cloud cover | GFS · ICON · HRRR (ECMWF open data has no layer split) |
cloud_ceiling | m | cloud-base height; null = unlimited | GFS · HRRR |
precipitation | mm/h | rate, all models normalized from their native accumulations | ECMWF IFS · GFS · ICON · HRRR |
precip_type | code | integer enum: 0 none · 1 rain · 2 snow · 3 freezing rain · 4 ice pellets; 0 whenever precipitation is served alongside and is 0 | GFS · HRRR |
snow_depth | m | on the ground | GFS · ICON · HRRR |
swe | mm | snow water equivalent | GFS · HRRR |
cape | J/kg | thunderstorm potential | ECMWF IFS · GFS · ICON · HRRR |
cin | J/kg | convective inhibition (negative = capped) | GFS · HRRR |
mslp | hPa | mean-sea-level pressure | ECMWF IFS · GFS · ICON · HRRR |
visibility | km | surface visibility | GFS · HRRR |
Where several models cover a point, the finest genuinely-available source wins per variable and time step (a 2.2 km convection-permitting model beats the global one inside its footprint) - deterministic per cell, no averaging across resolutions. A handful of variables (cloud_ceiling, precip_type, swe, cin, visibility) are published only by the NOAA models, so they carry GFS/HRRR resolution everywhere.
Field invariants
The envelope is identical to Waters: a 200 body has exactly query, resolved, variables, steps and meta, passed through verbatim from the Heavens grid worker (the same serving code as Waters, a different bake). The full tapered 10-day horizon is always returned - there is no horizon param. Headers are the same everywhere: X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset (per key, per product) on responses that reach the handler and on 429 (the early auth gates 401/402/403 skip it), X-Quota-* when a quota is set, conditional gzip; keyed 200s carry no Cache-Control. Per field, this is what you may rely on:
| Field | You may rely on |
|---|---|
query.lat / query.lon | Numbers - your request coordinates echoed back exactly as parsed to float. Per-point; never null on a 200. |
resolved.grid_cycle | String, ISO 8601 UTC (+00:00) - the base time of the loaded Heavens bake; always === steps[0].time. Grid-global: one value per loaded grid, never per-cell or per-region, snapshotted once per request. Monotone non-decreasing per serving worker - but the workers hot-reload staggered during a publish, so consecutive requests can straddle workers and step back by exactly one cycle for under a minute, then it is monotone again. Heavens and Waters are separate bakes on separate workers: their grid_cycle values are unrelated and roll at different moments. Null only for a bake that has never been published. |
resolved.published_at / bake_id | published_at is the ISO 8601 UTC time this grid was PUBLISHED; bake_id the same instant as a compact monotonic id (YYYYMMDDTHHMM). Unlike grid_cycle (the base time, which only advances on a new-base bake), both change on EVERY publish including same-base re-bakes with fresher inputs - key your refresh cadence on published_at to catch every publication. baked_at is an exact alias of published_at (same value). Grid-global; null only if the publish name isn't the expected stamp. |
resolved.nearest_cell_km | Number (2 decimals), great-circle km to the served cell center. Per-point. On a 200 it is 0..60 - the hard contract bound - though on the land+sea population-tiered grid (~11 km floor) a >60 km miss is practically unreachable, so real-world values are small. Never null on a 200. |
resolved.cell | [cell_lat, cell_lon] of the nearest grid-cell center, floats at 4 decimals. Per-point; never null on a 200. cell and nearest_cell_km are the only snap fields - there is no snapped_from_land. |
resolved.steps | Integer ≥ 1, the count of timesteps in the loaded bake; always === steps.length. Grid-global per cycle - identical for every point on the same grid_cycle. |
variables | Array of strings - the served variable keys. ?vars= preserves your request order filtered to keys that exist; unknown names are silently dropped (all-unknown gives variables: [] and empty values, still a 200); omitted means the full bake key list. |
steps | Array of { time, values }, length === resolved.steps, ascending. The axis is tapered - hourly to +48 h, 3-hourly to +120 h, 6-hourly to +240 h - so spacing is not uniform: always read steps[].time rather than assuming an interval. |
steps[].time | String, ISO 8601 UTC (+00:00); steps[0].time === resolved.grid_cycle; strictly increasing. |
steps[].values.<var> | Number rounded to 2 decimals, or null. Null means that variable has no data at that cell and step (for example a regional refiner's shorter horizon has ended and no fallback existed) - never zero-filled. 0 is always a real zero. |
steps[].values.precip_type | Integer categorical code 0-4, never fractional - the enum is enforced server-side (baked nearest-neighbour, and serving cleans any older blended bake). When precipitation is served in the same response and is 0 at a step, precip_type is forced to 0 - a type is meaningless without precip. (Requesting precip_type alone skips that consistency pass.) null when unbaked at that step. |
meta.* | Same constants and semantics as Waters: model = "pre-baked region-aware grid"; cache = "static-grid"; took_ms = grid-side lookup milliseconds (2 decimals, diagnostic only - not end-to-end latency); disclaimers = one element, "Not for navigation or safety-of-life decisions; modelled estimate, as is." |
In short: resolved.grid_cycle, resolved.steps and the time axis are properties of the loaded grid - identical for every point until the next bake swaps in - while nearest_cell_km, cell and the values are properties of your point. Anything not listed here is not part of the contract.
How and when the data updates
The grid is re-baked whenever an upstream model publishes a new cycle, at most every ~2-3 hours, and swapped in atomically - you never read a half-written forecast. Read the data's age from resolved.grid_cycle (the forecast base time) and each step's time.
| Source | Feeds | Typical cycles |
|---|---|---|
| ECMWF IFS 0.25° (~27 km) | global backbone, full horizon | 2× / day |
| NOAA GFS 0.25° (~27 km) | global keystone - the only global source of cloud layers, precip type, visibility | 4× / day |
| DWD ICON-EU 6.5 km | Europe incl. Scandinavia, to +48 h | 4× / day |
| DWD ICON-D2 2.2 km | Central Europe, to +48 h | 8× / day |
| NOAA HRRR 3 km | Continental US, to +18 h | hourly |
Horizon & time steps: 10 days (240 h) with a tapered axis - hourly to +48 h, 3-hourly to +120 h, 6-hourly to +240 h (~93 steps). The regional models are deliberately short-range: beyond +48 h (Europe) / +18 h (US) every point hands over to the ~27 km global backbone. The grid's fine cells are real regional-model structure inside those footprints; elsewhere they are honest interpolation of the global models - Heavens aims to be the most detailed and most honest open-data forecast, not to invent resolution.
Errors & retries
All errors share the one envelope from the docs index: { "error": { "code": ..., "message": ... } }. The full Heavens catalog, including which responses carry a Retry-After header:
| HTTP | Code | When it fires | Retry-After |
|---|---|---|---|
| 400 | bad_request | lat or lon missing. A present-but-non-numeric coordinate is caught deeper: the grid's own 400 is retried as transient and surfaces as 503 heavens_error, not 400. | - |
| 401 | unauthorized | Missing or invalid API key. | - |
| 402 | quota_exceeded | Monthly quota exceeded - carries X-Quota-Limit / X-Quota-Used headers. | - |
| 403 | product_not_enabled | The key's account does not have the Heavens product enabled. | - |
| 422 | no_cell | Nearest grid cell more than 60 km away - practically unreachable on the land+sea grid, but contractually reserved. Never retried internally. Note the code is no_cell here vs no_ocean_cell on Waters. | - |
| 429 | rate_limited | Per-key rate bucket exhausted; carries X-RateLimit-Limit. | seconds until the bucket refills |
| 500 | internal_error | Unexpected server error. | - |
| 503 | coming_soon | Dormant today (Heavens is live) - fires for every key only if the product is switched off. | - |
| 503 | not_yet_available | The Heavens grid worker is not configured on the serving host. | - |
| 503 | heavens_error | Transient grid failure after two 6 s tries with jittered backoff - a stall, a worker hot-reload, a transport error or an upstream 5xx. | 30 |
Only 429 rate_limited and 503 heavens_error carry Retry-After, and they are the only two worth retrying blindly. Honor the header: stalls clear in seconds to tens of seconds, and because the serving workers reload staggered during a publish, a 30 s retry lands on a worker that is serving. Everything else is deterministic - fix the request or the account, not the timing.
Try it without a key
/v1/demo/heavens?lat=..&lon=.. is the keyless showcase window (same grid, CDN-cached, no SLA) - fine for evaluation, not for production. Production traffic belongs on /v1/heavens with your key. Sun and moon times shown on the Heavens page are computed client-side (astronomy, not forecast data) and are not API variables.