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. 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 |
The response resolves your coordinate to the nearest grid cell (resolved.nearest_cell_km tells you the snap distance). Points more than 60 km from any ocean cell return 422 no_ocean_cell.
Response
{
"query": { "lat": 58.0, "lon": 11.0 },
"resolved": { "nearest_cell_km": 1.2, "cell": [58.01, 11.02],
"grid_cycle": "2026-07-12T06:00:00+00:00", "steps": 93 },
"variables": ["wave_height", "wave_period_peak", "wind_speed", ...],
"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.
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 | sea surface | NOAA RTOFS anchored to MUR / OSTIA / OISST analyses |
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 five keys - query, resolved, variables, 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. |
steps | Array of { time, values } objects, length equal to resolved.steps, strictly ascending in time. The axis is tapered, not uniform (hourly to +48 h, 3-hourly to +120 h, 6-hourly to +240 h) - read each step's time, never assume fixed spacing. |
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".
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 |
| 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 anchoring analyses | daily |
| US IOOS + EU HF-radar | coastal currents | 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). Short-range regional detail hands over to the global backbone where a fine model's horizon ends.
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 or lon missing from the query. A present but non-numeric lat/lon is caught deeper in the stack and surfaces as 503 waters_error, not 400. | - |
| 401 | unauthorized | Missing or invalid API key (Authorization: Bearer <key> or ?key=). Checked before everything else. | - |
| 402 | quota_exceeded | Monthly points quota exceeded. Carries X-Quota-Limit / X-Quota-Used 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.