Example clients
Two tiny clients, no framework, no build step. Copy one into your project and delete what you don't need. Both handle the two responses that matter in practice: 429 (quota or per-minute limit, with Retry-After) and 403 (key without that scope).
neptun.py Python 3.9+ · requests
"""Minimal Neptun API client — tides, currents, gauges, notices, locks, shelter.
pip install requests
export NEPTUN_KEY=mapi_...
python neptun.py
Docs: https://neptun.marine-api.com/docs/
"""
import os
import sys
import requests
BASE = "https://neptun.marine-api.com/v1"
class Neptun:
def __init__(self, key=None, base=BASE, timeout=20):
self.key = key or os.environ.get("NEPTUN_KEY", "")
if not self.key:
raise SystemExit("Set NEPTUN_KEY (get one at https://neptun.marine-api.com/pricing/)")
self.base, self.timeout = base, timeout
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {self.key}"
def _get(self, pfad, **params):
r = self.session.get(f"{self.base}/{pfad}", params=params, timeout=self.timeout)
if r.status_code == 429:
# Monthly quota or the per-minute rate limit. Retry-After says how long to wait.
raise RuntimeError(f"rate limited, retry after {r.headers.get('Retry-After', '?')}s")
if r.status_code == 403:
raise RuntimeError("your key has no scope for this endpoint")
r.raise_for_status()
return r.json()
# Tides and currents work worldwide (model-based), the rest is measured/official data.
def tides(self, lat, lon, days=2):
return self._get("tides", lat=lat, lon=lon, days=days)
def currents(self, lat, lon, hours=12):
return self._get("currents", lat=lat, lon=lon, hours=hours)
def levels(self, lat, lon, radius=25):
return self._get("levels", lat=lat, lon=lon, radius=radius)
def warnings(self, lat, lon, radius=50, lang="en"):
return self._get("warnings", lat=lat, lon=lon, radius=radius, lang=lang)
def locks(self, lat, lon, radius=25):
return self._get("locks", lat=lat, lon=lon, radius=radius)
def shelter(self, lat, lon, radius=25, protected_from=None):
p = {"lat": lat, "lon": lon, "radius": radius}
if protected_from:
p["protected_from"] = ",".join(protected_from)
return self._get("shelter", **p)
def me(self):
return self._get("me")
if __name__ == "__main__":
n = Neptun()
lat, lon = 54.3233, 10.1394 # Kiel
info = n.me()
print(f"plan {info.get('plan')} · quota {info['quota']['used']}/{info['quota']['limit']}")
t = n.tides(lat, lon)
for e in (t.get("extremes") or [])[:4]:
print(f" {e['typ']} {e['hoehe']:>5} m ts={e['ts']}")
# Every response carries `attribution` — the licences require you to carry it through.
print("attribution:", t.get("attribution", "")[:80])
neptun.mjs Node 18+ · no dependencies
// Minimal Neptun API client (Node 18+, no dependencies).
//
// export NEPTUN_KEY=mapi_...
// node neptun.mjs
//
// Docs: https://neptun.marine-api.com/docs/
const BASE = "https://neptun.marine-api.com/v1";
export class Neptun {
constructor(key = process.env.NEPTUN_KEY, base = BASE) {
if (!key) throw new Error("Set NEPTUN_KEY (get one at https://neptun.marine-api.com/pricing/)");
this.key = key;
this.base = base;
}
async #get(path, params = {}) {
const url = new URL(`${this.base}/${path}`);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) url.searchParams.set(k, v);
}
const r = await fetch(url, { headers: { Authorization: `Bearer ${this.key}` } });
// 429 = monthly quota exhausted OR the per-minute limit; Retry-After tells you how long.
if (r.status === 429) throw new Error(`rate limited, retry after ${r.headers.get("retry-after") ?? "?"}s`);
if (r.status === 403) throw new Error("your key has no scope for this endpoint");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
// Tides and currents work worldwide (model-based); the rest is measured/official data.
tides(lat, lon, days = 2) { return this.#get("tides", { lat, lon, days }); }
currents(lat, lon, hours = 12) { return this.#get("currents", { lat, lon, hours }); }
levels(lat, lon, radius = 25) { return this.#get("levels", { lat, lon, radius }); }
warnings(lat, lon, radius = 50, lang = "en") { return this.#get("warnings", { lat, lon, radius, lang }); }
locks(lat, lon, radius = 25) { return this.#get("locks", { lat, lon, radius }); }
shelter(lat, lon, radius = 25, protectedFrom = null) {
return this.#get("shelter", { lat, lon, radius, protected_from: protectedFrom?.join(",") });
}
me() { return this.#get("me"); }
}
if (import.meta.url === `file://${process.argv[1]}`) {
const n = new Neptun();
const [lat, lon] = [54.3233, 10.1394]; // Kiel
const info = await n.me();
console.log(`plan ${info.plan} · quota ${info.quota.used}/${info.quota.limit}`);
const t = await n.tides(lat, lon);
for (const e of (t.extremes ?? []).slice(0, 4)) {
console.log(` ${e.typ} ${String(e.hoehe).padStart(5)} m ts=${e.ts}`);
}
// Every response carries `attribution` — the licences require you to carry it through.
console.log("attribution:", (t.attribution ?? "").slice(0, 80));
}
Every response carries an attribution field — the providers' licences require you to carry it through. Model values — not for navigation.