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

← All docs

Waters API

The marine water-state forecast: waves (including resolved swell partitions), wind, surface currents, sea-surface temperature and depth for any ocean coordinate, out to 10 days for waves and wind. Currents and SST come from an ocean model with a shorter reach and end around day 7-8; every response states where each variable actually ends in coverage (see horizon per variable). Served from Mareel's own pre-baked global grid - a region-aware blend of the best open models, finest near the coast (~2-3 km) and coarser offshore, with coastal wave physics (shoaling, refraction, depth-limited breaking) baked in nearshore. Waters is deliberately tide-free: tides are the separate Tides product; combine the two client-side.

Endpoint

GET /v1/waters

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), so you get the fieldat your point, not a snapped cell. resolved.spatial_interp is "idw" and resolved.neighbors is how many cells fed it; resolved.nearest_cell_km is the distance to the nearest cell for reference. Pass interp=nearest for the raw nearest cell instead. Points more than 60 km from any ocean cell return 422 no_ocean_cell.

Response

{
  "query":    { "lat": 58.0, "lon": 11.0 },
  "resolved": { "spatial_interp": "idw", "neighbors": 4, "nearest_cell_km": 1.2, "cell": [58.01, 11.02],
                "interpolated": true, "interval_min": 60,
                "grid_cycle": "2026-07-12T06:00:00+00:00", "steps": 241 },
  "variables": ["wave_height", "wave_period_peak", "wind_speed", ...],
  "coverage": { "wave_height": "2026-07-22T06:00:00+00:00",   // last step this variable has data for,
                "wind_speed":  "2026-07-22T06:00:00+00:00",   // at THIS point
                "sst":         "2026-07-19T18:00:00+00:00", ... },
  "coverage_gaps": {},                                        // any missing instants INSIDE the coverage
  "steps": [
    { "time": "2026-07-12T06:00:00+00:00",
      "values": { "wave_height": 1.4, "wave_direction": 245.0, "swell1_height": 1.1,
                  "wind_speed": 12.3, "sst": 16.8, "current_speed": 0.3,
                  "current_hazard": "calm", "depth_m": 42.5, ... } },
    ...
  ],
  "meta": { "model": "pre-baked region-aware grid", "cache": "static-grid",
            "took_ms": 1.8, "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. coverage tells you where that is: for every served variable, the last step it has data for at your point. Compare it with steps[steps.length-1].time to separate a variable's real horizon from a gap, without probing the series yourself. coverage_gaps completes the picture: any instants that are missing inside that range, listed per variable, so a range you trust never hides a hole. It is an empty object when the series is complete, which is the normal case. The wave partitions are excluded from it on purpose: windwave_*, swell1_* and swell2_* are null when the sea state does not contain that component (no distinct wind sea, no second swell train), which is an answer rather than a hole. A gap in a TOTAL field (wave_height, wave_period_*, wave_direction) is still reported.

Variables

KeyUnitConventionPrimary sources
wave_heightmsignificant heightNOAA GFS-Wave · Copernicus MFWAM · regional basin models
wave_period_peak / wave_period_meanssame as waves
wave_directiondegcoming fromsame as waves
swell1_height / swell1_period / swell1_directionm / s / degprimary swell partition, direction fromsame as waves
swell2_height / swell2_period / swell2_directionm / s / degsecondary swell partitionsame as waves
windwave_height / windwave_period / windwave_directionm / s / deglocal wind seasame as waves
wind_speed / wind_gustknots10 mECMWF IFS · ICON-D2/EU · HRRR · HRDPS regionally
wind_directiondegcoming fromsame as wind
current_speedm/ssurfaceNOAA RTOFS · US/EU HF-radar nearshore
current_directiondeggoing tosame as currents
current_hazardlabelcalm / strong / danger (derived from current_speed)derived
sst°Cbulk temperature of the ocean model's surface layer, not skin SSTNOAA RTOFS; MUR / OSTIA / OISST analyses as fallbacks where RTOFS has no cell
depth_mmtide-free depthGEBCO 2024 (15″)

Where several models cover a point, the finest genuinely-available source wins (e.g. a 2 km regional model beats the global one inside its footprint) - deterministic per cell, no averaging across resolutions.

Field invariants

What a client may rely on, field by field. A 200 body has exactly seven keys - query, resolved, variables, coverage, coverage_gaps, steps, meta - passed through verbatim from the grid; errors always use { "error": { "code", "message" } }. Responses that reach the handler (200/400/422 and handler 5xx) carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (the per-key, per-product bucket), as does 429 - the early auth gates (401/402/403) do not - plus X-Quota-Limit and X-Quota-Used when your plan has a quota; bodies over 1 KB are gzipped when you accept it, and keyed 200s carry no cache header. The keyless /v1/demo/waters mirror adds a _proxy_ms diagnostic - the keyed route never does.

FieldYou may rely on
query.lat / query.lonNumbers - your coordinates echoed back exactly as parsed, not rounded, not snapped. Per point. Never null on a 200.
resolved.grid_cycleString, ISO 8601 UTC with a +00:00 suffix. The first timestep of the loaded bake, so it always equals steps[0].time. Grid-global: one value per loaded grid, never per cell or per region, and a single response never mixes two cycles. Monotone non-decreasing per serving process - see the rollout caveat below.
resolved.published_at / bake_idpublished_at is the ISO 8601 UTC time this grid was PUBLISHED; bake_id is the same instant as a compact monotonic id (YYYYMMDDTHHMM). Unlike grid_cycle (the forecast BASE time, which only advances on a NEW-base bake), these change on EVERY publish - including a same-base re-bake with fresher inputs. Key your refresh cadence on published_at to catch every publication, not every other one. 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 - great-circle kilometres from your point to the served cell center, rounded to 2 decimals. Per point. On any 200 it is 0..60: beyond 60 km the request is 422 no_ocean_cell instead, so a 200 proves the snap distance is within 60 km. Never null on a 200.
resolved.cellTwo-element array [cell_lat, cell_lon] - the center of the ocean cell actually served, floats rounded to 4 decimals. Per point. This pair plus nearest_cell_km is the full snap disclosure. Never null on a 200.
resolved.stepsInteger, at least 1 - the timestep count of the loaded bake; always equals steps.length. Grid-global per cycle: identical for every point on the same grid_cycle, changing only when a new bake is published. Never null.
variablesArray of strings - the variable keys actually served. With ?vars= it preserves your request order filtered to keys that exist in the bake; unknown names are silently dropped (all-unknown yields variables: [] and empty values, still a 200, not an error). Without ?vars=, the full baked list in bake order. Grid-global per cycle.
coverageObject keyed by the same names as variables - for each, the ISO 8601 UTC time of the last step that has data at this point, or null if the variable never covers it. Per point, not grid-global: a regional-only field ends earlier outside its footprint. Waves and wind normally reach the end of steps; sst and the current fields end at the ocean model's reach. Present on every 200.
coverage_gapsObject, keyed only by variables that have a hole: an array of the ISO 8601 UTC instants that are missing between that variable's first and last valid step at this point, ascending. Empty object when every served variable is continuous over its range, which is the normal case. Steps missing at the tail are the horizon, reported by coverage, and never appear here.
stepsArray of { time, values } objects, length equal to resolved.steps, strictly ascending in time. Uniform at your interval= (default hourly), interpolated from the baked model steps (hourly to +48 h, then 3-hourly, then 6-hourly) - resolved.interpolated is true and resolved.interval_min gives the spacing. Confidence decreases with range; the long-range hours are interpolated, not extra model detail.
steps[].timeString, same ISO 8601 UTC +00:00 format as grid_cycle. steps[0].time equals resolved.grid_cycle. Strictly increasing.
steps[].values.<var>Number rounded to 2 decimals, or null. null means the baked value was not finite - no data for that variable at that cell and step. Values are never zero-filled to hide gaps, so 0 is always a real physical zero. Per point, per step, per variable.
steps[].values.current_hazardDerived string, exactly one of calm / strong / danger, computed from current_speed in m/s (>1.0 danger, >0.5 strong, else calm). Additive and optional: present only when current_speed is among the served variables and finite at that step, absent otherwise - never null. A data label, not a rating, and it does not appear in variables.
meta.modelConstant string "pre-baked region-aware grid". Global, never varies per point.
meta.cacheConstant string "static-grid". Global.
meta.took_msNumber - milliseconds of grid lookup time only, rounded to 2 decimals (excludes the web tier, retries and network). Per-request diagnostic; no bound is guaranteed and it is not contract data.
meta.disclaimersArray of strings, currently exactly one constant element (the not-for-navigation disclaimer). Global; may gain entries but never becomes null.

The one honest wrinkle in resolved.grid_cycle: when a new bake is published, the serving workers hot-reload staggered (roughly 30 s apart) so the grid never stops serving. During that window two consecutive requests can land on different workers and see the new cycle then the old one - grid_cycle may regress by exactly one cycle for under a minute, then is monotone again. Key your caches by the cycle value, not by "latest ever seen".

Field slices for map overlays

GET /v1/waters/field

For a heat-map 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 a direction field uses a circular mean and a categorical field (precip_type) takes the nearest cell, never a fractional average. Pass interp=nearest for the raw nearest cell instead, 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": "wave_height", "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": 0.2, "max": 4.8,
  "pack": "int16", "dtype": "int16-le", "scale": 0.00014, "offset": 0.2, "nodata": -32768,
  "data": "<base64 int16, length cols*rows>"   // v = code == -32768 ? NaN : code*scale + offset
}

Pixels whose nearest cell is off-grid (more than 60 km away, e.g. deep inland) are nodata (null in the json pack). An unknown var returns 422 with the available keys.

Every slice carries var_last_valid: the last step this variable has data for in this bounding box. Past it the raster is legitimately empty, because the variable's model does not reach that far (see horizon per variable). A slice with no finite pixel at all sets nodata: true and a note, and adds gap: true when the step falls inside the variable's range, so an empty tile always says which of the two it is.

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
NOAA GFS-Wave 0.25°waves global4× / day
Copernicus MFWAM 1/12° + Baltic/Black Sea/IBI/Med basin modelswaves regional1-2× / day
ECMWF IFS 0.25°wind global2× / day
NOAA GFS 0.25°wind long-range fill (incl. gust) where the finer sources end4× / day
MET Norway MEPS 2.5 kmwind incl. native gust, Nordics to +66 h8× / day
DWD ICON-D2 2.2 km / ICON-EU 6.5 km, NOAA HRRR 3 km, ECCC HRDPS 2.5 kmwind regionalhourly - 6-hourly
NOAA RTOFS 1/12°currents + SSTdaily
JPL MUR / UKMO OSTIA / NOAA OISSTSST fallbacks below RTOFS (not blended into it)daily
US IOOS + EU HF-radarcoastal currentshourly

Horizon per variable

The grid's time axis runs 10 days, but a variable only carries data as far as the model behind it forecasts. Waves and wind reach the end of the axis; currents and SST come from NOAA RTOFS, an 8-day ocean forecast whose cycle base is up to a day behind the grid's, so they typically end around day 7-8 and are null after that, over open water included. This is the model's reach, not a gap in the bake.

VariablesReachesSet by
wind, gusts, wind direction~9.5-10 daysECMWF IFS, GFS filling the far tail (incl. gust)
waves, swell partitions, wind sea~9-9.5 daysGFS-Wave / MFWAM (cycle lag eats the last hours)
sst, current_speed, current_direction~7-8 daysNOAA RTOFS (192 h from its own daily base)
depth_mevery step (static)GEBCO 2024

Do not hard-code these numbers: read coverage from the response, which gives the last valid step per variable at your point, and coverage_gaps for anything missing inside that range. A field slice reports the same for the variable it serves in var_last_valid, and marks an empty slice that falls inside the range with gap: true rather than letting it read as the end of the forecast.

Source cadence is not uniform, and the bake resolves that rather than passing it on: RTOFS posts 6-hourly for two days and then once a day, so each step between two model steps is interpolated between them (circularly for directions, nearest for categorical codes). A gap wider than 30 h is left as a gap instead of being interpolated over, so a genuine upstream outage still shows up in coverage_gaps rather than being papered over.

Resolution: the response is a uniform series at your interval= (default hourly); it is interpolated from the model's native cadence, which is hourly to +48 h, then 3-hourly, then 6-hourly (~93 baked steps). You are billed on that native density, so late hours cost less. Short-range regional detail hands over to the global backbone where a fine model's horizon ends. 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

Every error uses the shared envelope from the docs index - { "error": { "code", "message" } }. The full catalog for /v1/waters, with the retry signal where one exists:

HTTPCodeMeaningRetry-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/waters/batch (which adds the slot position). A bad request is never answered with a retryable status.-
401unauthorizedMissing or invalid API key (Authorization: Bearer <key> or ?key=). Checked before everything else.-
402quota_exceededMonthly credit budget exceeded. Carries X-Quota-Limit / X-Quota-Used (in credits) so you can see where you stand.-
403product_not_enabledValid key whose account does not include Waters.-
422no_ocean_cellThe nearest ocean grid cell is more than 60 km from your point - genuinely no Waters coverage (deep inland). Returned immediately and never retried on our side; it is a fact about the coordinate, not a fault.-
429rate_limitedPer-key per-minute rate limit exhausted. Carries X-RateLimit-Limit.seconds until the next call is allowed
500internal_errorUnexpected error on our side.-
503coming_soonThe product is switched off. Dormant today - Waters is live - but if it were ever flipped back it fires for every key, regardless of entitlement.-
503not_yet_availableServing misconfiguration on our side - the grid worker address is missing. Not something a client can fix.-
503waters_errorTransient grid failure after our own two attempts with backoff - a worker mid-reload or a transport stall. These clear in seconds to tens of seconds.30

The one worth automating: on 503 waters_error, honor Retry-After: 30 and retry once - the serving workers reload staggered, so the retry usually lands on a worker that is serving. Do not retry 422 no_ocean_cell: the answer will not change.

Try it without a key

/v1/demo/waters?lat=..&lon=.. is the keyless showcase window (same grid, CDN-cached, no SLA) - fine for evaluation, not for production. Production traffic belongs on /v1/waters with your key.

Observations

GET /v1/observations

Live in-situ measurements (wave buoys, HF-radar) near a coordinate - the ground truth alongside the forecast. Same Waters entitlement.