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
| Param | Type | Default | Notes |
|---|---|---|---|
lat, lon | number | required | -90..90 / -180..180 |
vars | comma list | all | subset of the variable keys below |
days / hours | int | full ~10 d | trim the horizon |
interval | min | native | 60 | uniform series spacing; native = raw baked cadence |
interp | idw | nearest | idw | spatial: IDW to your exact point, or the raw nearest cell |
k | int | 4 | neighbour 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
| Key | Unit | Convention | Primary sources |
|---|---|---|---|
wave_height | m | significant height | NOAA GFS-Wave · Copernicus MFWAM · regional basin models |
wave_period_peak / wave_period_mean | s | same as waves | |
wave_direction | deg | coming from | same as waves |
swell1_height / swell1_period / swell1_direction | m / s / deg | primary swell partition, direction from | same as waves |
swell2_height / swell2_period / swell2_direction | m / s / deg | secondary swell partition | same as waves |
windwave_height / windwave_period / windwave_direction | m / s / deg | local wind sea | same as waves |
wind_speed / wind_gust | knots | 10 m | ECMWF IFS · ICON-D2/EU · HRRR · HRDPS regionally |
wind_direction | deg | coming from | same as wind |
current_speed | m/s | surface | NOAA RTOFS · US/EU HF-radar nearshore |
current_direction | deg | going to | same as currents |
current_hazard | label | calm / strong / danger (derived from current_speed) | derived |
sst | °C | bulk temperature of the ocean model's surface layer, not skin SST | NOAA RTOFS; MUR / OSTIA / OISST analyses as fallbacks where RTOFS has no cell |
depth_m | m | tide-free depth | GEBCO 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.
| Field | You may rely on |
|---|---|
query.lat / query.lon | Numbers - your coordinates echoed back exactly as parsed, not rounded, not snapped. Per point. Never null on a 200. |
resolved.grid_cycle | String, 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_id | published_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_km | Number - 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.cell | Two-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.steps | Integer, 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. |
variables | Array 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. |
coverage | Object 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_gaps | Object, 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. |
steps | Array 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[].time | String, 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_hazard | Derived 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.model | Constant string "pre-baked region-aware grid". Global, never varies per point. |
meta.cache | Constant string "static-grid". Global. |
meta.took_ms | Number - 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.disclaimers | Array 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.
| Param | Type | Default | Notes |
|---|---|---|---|
var | string | required | one variable key (see the table above) |
bbox | w,s,e,n | required | minlon,minlat,maxlon,maxlat |
cols, rows | int | 256 | raster size, max 512 each |
t | hours | iso | 0 | hours from the forecast base, or an ISO instant - snapped to the nearest baked step |
step | int | - | advanced: a raw native step index (iterate every baked frame) |
interp | idw | nearest | idw | per-pixel IDW, or the raw nearest cell |
k | int | 4 | neighbour cells for the IDW |
pack | json | int16 | json | int16 = 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.
| Source | Feeds | Typical cycles |
|---|---|---|
| NOAA GFS-Wave 0.25° | waves global | 4× / day |
| Copernicus MFWAM 1/12° + Baltic/Black Sea/IBI/Med basin models | waves regional | 1-2× / day |
| ECMWF IFS 0.25° | wind global | 2× / day |
| NOAA GFS 0.25° | wind long-range fill (incl. gust) where the finer sources end | 4× / day |
| MET Norway MEPS 2.5 km | wind incl. native gust, Nordics to +66 h | 8× / day |
| DWD ICON-D2 2.2 km / ICON-EU 6.5 km, NOAA HRRR 3 km, ECCC HRDPS 2.5 km | wind regional | hourly - 6-hourly |
| NOAA RTOFS 1/12° | currents + SST | daily |
| JPL MUR / UKMO OSTIA / NOAA OISST | SST fallbacks below RTOFS (not blended into it) | daily |
| US IOOS + EU HF-radar | coastal currents | hourly |
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.
| Variables | Reaches | Set by |
|---|---|---|
| wind, gusts, wind direction | ~9.5-10 days | ECMWF IFS, GFS filling the far tail (incl. gust) |
| waves, swell partitions, wind sea | ~9-9.5 days | GFS-Wave / MFWAM (cycle lag eats the last hours) |
sst, current_speed, current_direction | ~7-8 days | NOAA RTOFS (192 h from its own daily base) |
depth_m | every 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:
| HTTP | Code | Meaning | Retry-After |
|---|---|---|---|
| 400 | bad_request | lat/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. | - |
| 401 | unauthorized | Missing or invalid API key (Authorization: Bearer <key> or ?key=). Checked before everything else. | - |
| 402 | quota_exceeded | Monthly credit budget exceeded. Carries X-Quota-Limit / X-Quota-Used (in credits) so you can see where you stand. | - |
| 403 | product_not_enabled | Valid key whose account does not include Waters. | - |
| 422 | no_ocean_cell | The 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. | - |
| 429 | rate_limited | Per-key per-minute rate limit exhausted. Carries X-RateLimit-Limit. | seconds until the next call is allowed |
| 500 | internal_error | Unexpected error on our side. | - |
| 503 | coming_soon | The product is switched off. Dormant today - Waters is live - but if it were ever flipped back it fires for every key, regardless of entitlement. | - |
| 503 | not_yet_available | Serving misconfiguration on our side - the grid worker address is missing. Not something a client can fix. | - |
| 503 | waters_error | Transient 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.