Coming soon - Mareel Earth is in private development. The API is live, but accounts are not open yet. Early access: [email protected]

← All docs

Heavens API

Global weather for any coordinate on Earth - land, sea, cities, mountains - out to 10 days. Served from Mareel's own pre-baked activity-tiered grid: ~1 km cells across the Nordics at every settlement, golf course, marina, airfield, ski area and along the coast; ~2 km over cities worldwide; ~4 km over towns, coasts and all Nordic countryside; ~11 km everywhere else (9.5 million cells, no gaps). A region-aware blend of the best open models: observation-corrected MET Nordic 1 km and MEPS 2.5 km win over the Nordics, convection-permitting regional models (2.2-6.5 km) elsewhere in their footprints; 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

ParamTypeDefaultNotes
lat, lonnumberrequired-90..90 / -180..180
varscomma listallsubset of the variable keys below
days / hoursintfull ~10 dtrim the horizon
intervalmin | native60uniform series spacing; native = raw baked cadence
interpidw | nearestidwspatial: IDW to your exact point, or the raw nearest cell
kint4neighbour cells for the IDW interpolation

The value is IDW-interpolated to your exact coordinate across the k nearest grid cells (default 4, inverse-distance weighted) - you get the fieldat your point, not a snapped cell. resolved.spatial_interp is "idw", resolved.neighbors is how many cells fed it, and resolved.nearest_cell_km is the nearest-cell distance for reference (inside a city typically ~1 km). Pass interp=nearest for the raw nearest cell. 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": { "spatial_interp": "idw", "neighbors": 4, "nearest_cell_km": 0.9, "cell": [59.26, 15.20],
                "interpolated": true, "interval_min": 60,
                "grid_cycle": "2026-07-15T00:00:00+00:00", "steps": 241 },
  "variables": ["temperature_2m", "wind_speed", "cloud_total", "precipitation", ...],
  "coverage": { "temperature_2m": "2026-07-25T00:00:00+00:00", ... },  // last step per variable, at this point
  "coverage_gaps": {},                                                 // missing instants INSIDE that range
  "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). coverage gives, per variable, the last step it has data for at your point, and coverage_gaps lists anything missing inside that range (an empty object when the series is continuous), so you can tell a horizon from a hole without probing the series.

Variables

KeyUnitConventionPrimary sources
temperature_2m°C2 m air temperatureMET Nordic 1 km (Nordics, observation-corrected) · ECMWF IFS · GFS · ICON-EU/D2 · HRRR regionally
dewpoint_2m°C2 m dewpointsame as temperature
relative_humidity_2m%2 m relative humiditysame as temperature
wind_speed / wind_gustknots10 m sustained / gustMEPS 2.5 km (Nordics, native gust) · ECMWF IFS · GFS · ICON-EU/D2 · HRRR regionally
wind_directiondegcoming fromsame as wind
cloud_total%total cloud coverMET Nordic 1 km (Nordics, webcam-corrected) · ECMWF IFS · GFS · ICON · HRRR
cloud_low / cloud_mid / cloud_high%layer cloud coverGFS · ICON · HRRR (ECMWF open data has no layer split)
cloud_ceilingmcloud-base height; null = unlimitedGFS · HRRR
precipitationmm/hrate, all models normalized from their native accumulationsMET Nordic 1 km (Nordics, station+radar-corrected) · ECMWF IFS · GFS · ICON · HRRR
precip_typecodeinteger enum: 0 none · 1 rain · 2 snow · 3 freezing rain · 4 ice pellets; 0 whenever precipitation is served alongside and is 0GFS · HRRR
snow_depthmon the groundGFS · ICON · HRRR
swemmsnow water equivalentGFS · HRRR
capeJ/kgthunderstorm potentialECMWF IFS · GFS · ICON · HRRR
cinJ/kgconvective inhibition (negative = capped)GFS · HRRR
mslphPamean-sea-level pressureMET Nordic 1 km (Nordics) · ECMWF IFS · GFS · ICON · HRRR
visibilitykmsurface visibilityGFS · 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, coverage, coverage_gaps, 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:

FieldYou may rely on
query.lat / query.lonNumbers - your request coordinates echoed back exactly as parsed to float. Per-point; never null on a 200.
resolved.grid_cycleString, 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_idpublished_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_kmNumber (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.stepsInteger ≥ 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.
variablesArray 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.
stepsArray of { time, values }, length === resolved.steps, ascending. Uniform at your interval= (default hourly), interpolated from the model's native cadence (hourly to +48 h, then 3-hourly, then 6-hourly); resolved.interpolated=true and resolved.interval_mingives the spacing. Confidence decreases with range - long-range hours are interpolated.
steps[].timeString, 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_typeInteger 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.

Field slices for map overlays

GET /v1/heavens/field

For a weather-map layer or animated overlay, do not sweep the point endpoint cell by cell. One field call returns a whole raster tile of a single variable over a bounding box: a uniform cols×rows grid (row 0 = north, column 0 = west, row-major) at one time step. By default each pixel is IDW-interpolated from the nearest cells - the same spatial interpolation the point endpoint applies, so wind_direction uses a circular mean and precip_type takes the nearest cell, never a fractional average. Pass interp=nearest for the raw nearest cell, for a client that GPU-interpolates the texture itself. Each pixel is a delivered value, billed at the same per-value rate as a point (a 256×256 slice is ~66 credits) - the win is one call returning a whole raster instead of the ~65 000 point calls it replaces, not a cheaper unit price.

ParamTypeDefaultNotes
varstringrequiredone variable key (see the table above)
bboxw,s,e,nrequiredminlon,minlat,maxlon,maxlat
cols, rowsint256raster size, max 512 each
thours | iso0hours from the forecast base, or an ISO instant - snapped to the nearest baked step
stepint-advanced: a raw native step index (iterate every baked frame)
interpidw | nearestidwper-pixel IDW, or the raw nearest cell
kint4neighbour cells for the IDW
packjson | int16jsonint16 = compact base64 (scale/offset/nodata)

t is hours from the forecast base (step 0 = now), snapped to the nearest baked step - so t=24 is ~+24 h, the same hour scale as the point endpoint's hours=; an ISO instant works too. The response echoes the resolved step and time, so you always know exactly which frame you got. The baked axis is tapered (hourly to +48 h, then 3-hourly, then 6-hourly); to iterate every native frame directly, pass an explicit step= index instead of t.

{
  "var": "temperature_2m", "bbox": [-6, 43, -1, 47], "cols": 256, "rows": 256,
  "order": "row-major", "row0": "north", "col0": "west",
  "spatial_interp": "idw", "neighbors": 4,
  "step": 0, "time": "2026-07-12T06:00:00+00:00", "bake_id": "20260712T0600",
  "var_last_valid": "2026-07-22T06:00:00+00:00",   // where THIS variable ends in this bbox
  "min": 8.1, "max": 24.6,
  "pack": "int16", "dtype": "int16-le", "scale": 0.0005, "offset": 8.1, "nodata": -32768,
  "data": "<base64 int16, length cols*rows>"   // v = code == -32768 ? NaN : code*scale + offset
}

Pixels whose nearest cell is off-grid are nodata (null in the json pack). An unknown var returns 422 with the available keys. var_last_valid is the last step this variable has data for in this bounding box; a slice with no finite pixel at all also sets nodata: true and a note, so an empty tile is never ambiguous.

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.

SourceFeedsTypical cycles
ECMWF IFS 0.25° (~27 km)global backbone, full horizon2× / day
NOAA GFS 0.25° (~27 km)global keystone - the only global source of cloud layers, precip type, visibility4× / day
MET Norway MET Nordic 1 kmNordics: temperature, humidity, cloud, precipitation, pressure - bias-corrected against station, radar and webcam observations, to ~+58 h8× / day
MET Norway MEPS 2.5 kmNordics: wind incl. native gust, to +66 h8× / day
DWD ICON-EU 6.5 kmEurope incl. Scandinavia, to +48 h4× / day
DWD ICON-D2 2.2 kmCentral Europe, to +48 h8× / day
NOAA HRRR 3 kmContinental US, to +18 hhourly

Horizon & resolution: 10 days (240 h), served as a uniform series at yourinterval= (default hourly), interpolated from the native cadence - hourly to +48 h, then 3-hourly, then 6-hourly (~93 baked steps; you are billed on that native density). The regional models are deliberately short-range: beyond ~+58-66 h (Nordics) / +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. Pass interval=native (or resample=false) to get the raw baked steps at their native cadence with no temporal interpolation - for clients that interpolate themselves.

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:

HTTPCodeWhen it firesRetry-After
400bad_requestlat/lon missing, blank, non-numeric or out of range - the message names the offending value, e.g. Invalid lon "undefined". Identical rules and wording on /v1/heavens/batch (which adds the slot position). A bad request is never answered with a retryable status.-
401unauthorizedMissing or invalid API key.-
402quota_exceededMonthly quota exceeded - carries X-Quota-Limit / X-Quota-Used headers.-
403product_not_enabledThe key's account does not have the Heavens product enabled.-
422no_cellNearest 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.-
429rate_limitedPer-key rate bucket exhausted; carries X-RateLimit-Limit.seconds until the bucket refills
500internal_errorUnexpected server error.-
503coming_soonDormant today (Heavens is live) - fires for every key only if the product is switched off.-
503not_yet_availableThe Heavens grid worker is not configured on the serving host.-
503heavens_errorTransient 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.