Neptun·

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

Download

"""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

Download

// 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.

API reference · OpenAPI