On this page

Open Waters AIS: every AIS message heard by receivers around the world, pushed to you as it happens, plus a snapshot of where everything is right now.

Coming from aisstream.io? Change the hostname and keep your code: see Open Waters AIS vs aisstream.io.

Authentication

You can read without a token, within the anonymous tier in Limits. A token raises those limits and lets you send data in.

Get a token. Create a personal token in the browser, or via the API with POST /v1/keys. Tokens never expire. See Limits for what a personal token allows.

Send the token as Authorization: Bearer <token>, as ?key=<token> on the URL, as the APIKey field on the aisstream.io-compatible stream, as an HTTP Basic password (anything:<token>, which is what AIS-catcher’s USERPWD sends), or as the MQTT CONNECT password (wssmqtt://x:<token>@…). A token that is invalid, expired, revoked, used from outside its cidr, or short of the role for the action is refused. It never falls back to anonymous access.

Create a token over HTTP. Feeder, peer, partner, and admin tokens are issued by the operator.

POSThttps://ais.openwaters.io/v1/keys

Create a personal token

Create a personal token for an Ed25519 key you generate. It never expires. Generate a key and print its public half in the form pubkey expects:

openssl genpkey -algorithm ed25519 -out ais-key.pem
PUB=$(openssl pkey -in ais-key.pem -pubout -outform DER | tail -c 32 | base64 | tr '+/' '-_' | tr -d '=')

Sign the request with the same key, so nobody else can mint a token for it. The signature covers six lines joined by newlines, with no newline at the end: aiscast-key, pubkey, ts, 1 or 0 for bind_ip, name, and vessel_name, with an empty line for a name left out:

TS=$(date +%s)
printf 'aiscast-key\n%s\n%s\n0\n\n' "$PUB" "$TS" > msg
SIG=$(openssl pkeyutl -sign -inkey ais-key.pem -rawin -in msg | base64 | tr '+/' '-_' | tr -d '=\n')
curl -d "{\"pubkey\":\"$PUB\",\"ts\":$TS,\"sig\":\"$SIG\"}" https://ais.openwaters.io/v1/keys

Once a key has signed a request, every later request for it must be signed, and a request that names the station must be signed. Unsigned requests are still accepted for keys that have never signed, for clients that do not sign yet.

Tiers and limits are in docs/limits.md.

Request Body

FieldTypeRequiredDescription
pubkeystring✓The raw 32-byte Ed25519 public key, base64url-encoded without padding (43 characters). Not PEM or DER.
bind_ipboolean—Bind the token to the requesting address, so a receiver sending UDP from that address counts toward this token's feeder tier, and takes this request's names. The token then only works from that address.
namestring—The station's public name, up to 40 characters of letters, digits, spaces, and . , ' - & ( ) / #. Unique among stations. An empty string clears it. Needs sig. Generating again with a new name renames the station, and tokens already issued keep working.
vessel_namestring—The name of the boat the station is on, from a client that knows it, such as the Signal K plugin. Same rules as name, except that two stations may share it. Shown when there is no name. Needs sig.
tsinteger—Unix seconds, within 5 minutes of the server clock and later than the last signed request for this key.
sigstring—Ed25519 signature by pubkey over the signed lines, base64url without padding.
{
  "pubkey": "9BEqQjPRE0sZse-EbW1xUJa0K2iTBTV3TCqz1sPS7Fk",
  "bind_ip": false,
  "name": "Quissett Harbor",
  "ts": 1790000000,
  "sig": "2Wq…"
}

Example Response

{
  "token": "ak1.eyJraWQiOiJr...In0.X2lnbmF0dXJl...",
  "claims": {
    "kid": "k1",
    "sub": "ed25519:9BEqQjPRE0sZse-EbW1xUJa0K2iTBTV3TCqz1sPS7Fk",
    "role": "personal",
    "iat": 1755691200,
    "conns": 2,
    "rate": 50,
    "area": 400
  }
}

Live vessel traffic

Get real-time vessel traffic as it happens. The WebSocket carries the full event format and works in both directions. Server-Sent Events carries the same events over plain HTTP for curl or a browser. The aisstream.io-compatible endpoint is a drop-in for existing aisstream clients. NMEA delivers raw sentences for a chartplotter or your own decoder.

WSwss://ais.openwaters.io/v1/stream

Real-time events over WebSocket

Connect to wss://ais.openwaters.io/v1/stream, send a subscribe frame, and every AIS message from the area or vessels you asked for arrives as a JSON text frame. Add ?key=<token> or Authorization: Bearer for higher limits and publishing.

Messages you send

subscribe

Subscribe to bounding boxes, a list of MMSIs, or both. A message matches if it is inside any box or from any listed vessel, and MMSI matches include positionless messages such as static data. No events arrive until the first subscribe, and sending another replaces the subscription.

{"type": "subscribe", "bbox": [[41.2, -71.2, 42.0, -70.0], [58.5, 9.5, 60.5, 11.5]]}
{"type": "subscribe", "mmsi": [368168720, 257090090]}
{"type": "subscribe", "bbox": [[41.2, -71.2, 42.0, -70.0]], "mmsi": [368168720], "snapshot": true}

Bounding boxes are [minLat, minLon, maxLat, maxLon]. Total box area and the number of MMSIs are capped by your tier (see Limits); a subscription over the cap gets an error frame and leaves the current subscription unchanged. Omitting both bbox and mmsi means everything, which only a token without an area cap may do.

Add snapshot: true to start with the last known state of every vessel that already matches: positions held up to 30 minutes, plus a static message when a name or ship type is known. Replayed frames keep their original time and do not count against your rate. Live events interleave with the replay, so a vessel can appear in both, but nothing falls in the gap. When the original message is no longer held, the frame is rebuilt from the vessel cache and marked synthesized: true with no nmea and no id.

unsubscribe

{ "type": "unsubscribe" }

Stops events and keeps the socket open for publishing.

register

Creates a personal token in-band, equivalent to POST /v1/keys, with the same fields: sign it with the key, and name the station if you like. Works on an anonymous socket only and shares that endpoint’s rate limit.

{
  "type": "register",
  "pubkey": "<base64url Ed25519 public key>",
  "bind_ip": false,
  "name": "Quissett Harbor",
  "ts": 1790000000,
  "sig": "<base64url Ed25519 signature>"
}

The reply is a key frame, and the connection is upgraded to the token’s tier in place with a fresh welcome.

publish

Send what your receiver hears on the same socket. Needs a token. Each frame is answered in order with an ack.

{"type": "publish", "nmea": ["!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", "\\s:st1,c:1787234980*03\\!AIVDM,..."]}
{"type": "publish", "replay": true, "nmea": ["\\c:1787234980123*2A\\!AIVDM,..."]}

At most 1,000 sentences per frame and 6,000 per minute per key. Set replay: true when sending an offline backlog: sentences whose TAG c: time is more than 60 s old are archived and credited but not emitted live. Publishing is acceptance of the contributor agreement linked from welcome.terms.

Messages you receive

welcome

The first frame on every connection, with the limits in effect, so you can size boxes and pace reconnects without discovering limits by error.

{
  "type": "welcome",
  "sub": "ed25519:abc...",
  "role": "personal",
  "feeder": false,
  "terms": "https://github.com/openwatersio/aiscast/blob/main/docs/contributor-agreement.md",
  "limits": {
    "conns": 2,
    "rate": 50,
    "area": 400,
    "mmsis": 50,
    "publish": true,
    "publish_per_min": 6000,
    "publish_frame": 1000,
    "connects_per_min": 20
  }
}

conns is concurrent streams, rate is messages a second per stream (excess events are thinned, and the stream stays up), mmsis is vessels followed by MMSI per subscription, and area is the total subscribed box area in square degrees (a 20°×20° box is 400, and MMSI-only subscriptions do not count). An absent limit is unlimited, and a bbox list, when present, names the boxes every subscription must fit inside. feeder: true means a personal token is currently earning the contributor tier. On an anonymous socket, conns and connects_per_min are shared by everyone behind your address.

event

One per decoded message, deduplicated across every receiver that heard it.

{
  "type": "event",
  "id": "15f3d25469c1de49dbcb36baea34eed6",
  "time": "2026-08-20T15:25:54.342871Z",
  "source": "kystverket",
  "station": "kystverket/2573010",
  "channel": "A",
  "nmea": [
    "\\s:2573010,c:1787234980*03\\!BSVDM,1,1,,B,13noH:00000H@P@RSPEakGK@0D33,0*43"
  ],
  "mmsi": 257090090,
  "msg_type": "PositionReport",
  "lat": 59.88693333333333,
  "lon": 10.749376666666667,
  "message": {
    "MessageID": 1,
    "RepeatIndicator": 0,
    "UserID": 257090090,
    "Valid": true,
    "NavigationalStatus": 5,
    "...": "..."
  },
  "synthesized": false
}
  • msg_type is the aisstream.io type name and message is the decoded payload, using go-ais field names (UserID is the MMSI).
  • lat/lon are the vessel’s last known position, present on static messages too, so you can place every message on a map. Absent until a position has been heard.
  • time is when the message was transmitted: the source’s timestamp when it is within 30 s of our receive time, else our receive time.
  • id identifies the content, not the event. A static message rebroadcast unchanged every few minutes shares one id, so key events on (id, time). Deduplicating on id alone drops the rebroadcasts.
  • source and station say where it was heard: an open feed (kystverket, barentswatch, digitraffic, aishub, aisstream), an authenticated station (station:<sub>, whatever transport it fed over), or a volunteer UDP receiver (udp:<hash>, or mmsi:<n> once it has sent its own position). channel is A or B, or empty when the source was not NMEA.
  • nmea holds the sentences as received, or a re-encoded !AIVDM when the source was JSON rather than NMEA.
  • synthesized is true for anything not heard over VHF: a message rebuilt from a JSON source, a vessel’s own GPS report, or a snapshot reconstruction. Skip these if you only want receptions.

ack

One per publish frame, in order, with the number of sentences accepted.

{ "type": "ack", "n": 2 }

key

The reply to register: the new token and its claims, and name_error when a requested name was not stored.

{ "type": "key", "token": "ak1....", "claims": { "...": "..." } }

error

{ "type": "error", "error": "bbox not allowed for this key" }

A bad token or too many concurrent connections is followed by close 1008. A subscription or publish the tier does not allow is not; the socket stays open and the current subscription is unchanged. Inbound frames are limited to 256 KB, and a client that cannot keep up is closed with 1008 “client too slow”.

SSEhttps://ais.openwaters.io/v1/stream

Real-time Server-Sent Events

The same events over plain HTTP, for curl, a browser’s EventSource, or anything that would rather not carry a WebSocket library. Same tokens, same limits, subscribe only.

curl -N 'https://ais.openwaters.io/v1/stream?bbox=41.2,-71.2,42.0,-70.0&mmsi=368168720&snapshot=1'

The subscription comes from the query string and is fixed for the life of the connection: bbox=minLat,minLon,maxLat,maxLon (repeatable), mmsi=<mmsi>,<mmsi>,..., snapshot=1, and key=<token>. They match the way the WebSocket subscribe frame does.

Every frame is one data: line holding the same JSON the WebSocket sends, a welcome first and then event frames. Tell them apart by their type field; there is no SSE event name, so EventSource.onmessage sees all of them:

data: {"type":"welcome","sub":"anon:203.0.113.4","role":"anonymous","terms":"https://github.com/openwatersio/aiscast/blob/main/docs/contributor-agreement.md","limits":{"conns":2,"rate":20,"area":100,"mmsis":10,"publish":false,"connects_per_min":20}}

data: {"type":"event","id":"15f3d254...","time":"2026-08-20T15:25:54.342871Z","mmsi":257090090,"...":"..."}

Good to know:

There is no resume on reconnect. Use snapshot=1 to rebuild current state instead. A : comment arrives every 30 seconds while the stream is idle, so proxies do not drop a quiet subscription.

Send Accept-Encoding: gzip and the stream is compressed, flushed per event.

A bad request is an HTTP status, not a frame: 400 for a malformed or over-limit subscription (an unparseable MMSI is refused rather than dropped), 401 for a bad token, 429 for the connect or concurrent-connection caps. A client that falls behind receives {"type":"error","error":"client too slow"} and the stream ends.

WSwss://ais.openwaters.io/v0/stream

Drop-in replacement for the aisstream.io stream

Already using aisstream.io? Point your client at wss://ais.openwaters.io/v0/stream and use an Open Waters token as the APIKey. Nothing else changes: this endpoint is frozen to aisstream.io’s wire format.

Send one subscribe message within 3 seconds of connecting:

{
  "APIKey": "ak1....",
  "BoundingBoxes": [
    [
      [58.5, 9.5],
      [60.5, 11.5]
    ]
  ],
  "FiltersShipMMSI": ["257090090"],
  "FilterMessageTypes": ["PositionReport", "ShipStaticData"]
}
  • APIKey and BoundingBoxes are required. Keys are case-insensitive.
  • Each box is two [lat, lon] corners in any order, and several boxes are ORed together.
  • FiltersShipMMSI takes up to 50 nine-digit strings (more if the token allows). With a list present, the boxes are not counted against the token’s area cap, so a world box plus an MMSI list is fine on a personal token.
  • FilterMessageTypes takes any of the 24 aisstream.io type names.
  • Sending another subscribe message replaces the subscription.

Each frame is one decoded message in aisstream.io’s shape:

{
  "Message": {
    "PositionReport": {
      "Cog": 36.7,
      "Latitude": 49.47557666666667,
      "Longitude": 0.13138,
      "MessageID": 1,
      "NavigationalStatus": 0,
      "Sog": 0,
      "TrueHeading": 511,
      "UserID": 227006760,
      "Valid": true,
      "...": "..."
    }
  },
  "MessageType": "PositionReport",
  "MetaData": {
    "MMSI": 227006760,
    "MMSI_String": 227006760,
    "ShipName": "",
    "latitude": 49.47557666666667,
    "longitude": 0.13138,
    "time_utc": "2026-08-20 15:21:32.794168 +0000 UTC"
  }
}

MetaData.latitude/longitude are the vessel’s last known position, which is how positionless messages such as static data reach your bounding box. Messages from vessels with no known position are not delivered. Decoded fields use go-ais naming and AIS sentinel values (TrueHeading: 511, Cog: 360, Sog: 102.3).

Errors close the connection with a single frame: {"error": "Api Key Is Not Valid"}, {"error": "Subscription Object Is Malformed"}, {"error": "Bounding Box Not Allowed For This Key"}, {"error": "Too Many MMSI Filters For This Key"}, or {"error": "concurrent connections per user exceeded"}. A client that cannot keep up is closed with code 1008 “client too slow”.

WSwss://ais.openwaters.io/v1/nmea

Deduplicated raw NMEA back to contributors

Raw NMEA sentences for a chartplotter, OpenCPN, or your own decoder: every message Open Waters hears, deduplicated across all receivers, on wss://ais.openwaters.io/v1/nmea. It needs a token at the contributor tier or above, which a personal token earns by contributing 1,000 messages a day. Add ?bbox=minLat,minLon,maxLat,maxLon (repeatable) to limit it to an area.

websocat prints frames as lines:

websocat -t 'wss://ais.openwaters.io/v1/nmea?key=<your token>&bbox=51,-11,53,-8'

Chartplotters and Signal K read NMEA 0183 over UDP, not WebSockets. The same command bridges the two, sending each frame as one datagram to a local port. Point OpenCPN or Signal K at UDP port 10110 on that machine:

websocat -t 'wss://ais.openwaters.io/v1/nmea?key=<your token>&bbox=51,-11,53,-8' udp:127.0.0.1:10110

One text frame per message, sentences joined by CRLF. Each carries a NMEA 4.10 TAG block naming the station (s:), the time it was heard (c:, unix seconds), and the license its source publishes under (t:):

\s:2573010,c:1787234980,t:NLOD-2.0*2E\!BSVDM,1,1,,B,13noH:00000H@P@RSPEakGK@0D33,0*43

Messages from JSON sources (Digitraffic, AISHub, aisstream.io) are re-encoded as !AIVDM with t: naming their terms. Filter on t: if you only want receiver data. A message heard only by an unauthenticated UDP receiver is included once a trusted source has heard the same vessel.

Vessels

Where vessels are and where they have been, for a map or a lookup. No token needed, 120 requests per minute per address. A token can carry its own limit. For continuous updates, use a stream instead of polling.

GEThttps://ais.openwaters.io/v1/vessels

Find vessels

Vessel positions as a GeoJSON FeatureCollection of Point features. Filter to an area, a list of vessels, or both. The same token, area cap, and MMSI cap apply as to a /v1/stream subscription: anonymous requests must filter to at most 100 square degrees or 10 MMSIs, and only tokens without an area claim may omit both. Properties AIS marks "not available" are left out rather than set to a sentinel, so cog, sog, heading, nav_status, name, and type are present only when known.

An area answers with last known positions: every vessel heard in it in the last 30 minutes, and those last heard up to 7 days ago whose last report was stationary, meaning moored, at anchor, aground, under 1 knot, or an aid to navigation or base station. A vessel is usually still where it stopped when its receiver goes offline or it switches AIS off at its berth, while one last heard under way has moved on. max_age changes the 7 days and max_age_moving the 30 minutes for moving vessels. Past 30 minutes the answer takes at most 500 vessels, newest first, and sets truncated when there were more. A vessel named in mmsi answers with its last known position however long ago it was heard, moving or not, unless max_age or max_age_moving is given. The vector tiles answer by the same rules. Every feature carries seen, so an old position reads as where the vessel was.

q searches instead: vessels whose name starts with it, or whose MMSI does when it is digits, most recently heard first, at most 50. Each result carries near, the town or region nearest its position. around orders a search nearest first. bbox, mmsi, and max_age narrow a search, and it needs neither. kind, class, type, min_sog, and max_age_moving filter an area, a list, or a search, as they filter the vector tiles. A search refuses parameters it does not know, so a filter added later never changes an earlier answer.

Parameters

NameLocationTypeRequiredDescription
bboxquerystring—minLat,minLon,maxLat,maxLon. Latitude −90…90, longitude −180…180. Repeatable: the boxes are ORed and their total area counts against the token's area cap.
mmsiquerystring—Comma-separated MMSIs. ORed with bbox when both are given. An unparseable entry is a 400.
max_agequerystring—Oldest last report to include: whole seconds, a duration such as 90m or 24h, or all. Defaults to 7 days for an area and to all for vessels named in mmsi.
qquerystring—Search: a name prefix, case-insensitive, or an MMSI prefix when all digits. At least 2 characters.
aroundquerystring—lat,lon. Orders a search by distance from this point, nearest first, before the cap of 50 cuts it. Search only.
kindquerystring—Comma-separated: vessel, aton (aids to navigation), base (base stations), sar (search and rescue aircraft).
classquerystring—A or B, the transponder class from the position reports. A vessel whose class is not yet known is left out.
typequerystring—Comma-separated ITU ship and cargo type codes or ranges, such as 70-89 for cargo and tankers or 36,37 for sailing and pleasure craft. For an aid to navigation, the AtoN type. A vessel whose type is not yet known is type 0.
min_sogquerynumber—Only vessels at least this fast, in knots. A vessel with no known speed is left out.
max_age_movingquerystring—Oldest last report to include for a vessel whose last report was under way, that is, not stationary as /v1/vessels defines it: whole seconds, a duration such as 2h, or all. Defaults to 30 minutes for an area and to all for vessels named in mmsi.

Example Response

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": 368168720,
      "geometry": {
        "type": "Point",
        "coordinates": [
          -70.63165,
          41.680075
        ]
      },
      "properties": {
        "mmsi": 368168720,
        "kind": "vessel",
        "name": "CERULEAN",
        "type": 36,
        "cog": 237.4,
        "sog": 0,
        "heading": 237,
        "nav_status": 0,
        "seen": "2026-08-20T19:12:31Z",
        "source": "udp:84a377dcf41b",
        "station": "udp:84a377dcf41b",
        "msg_type": "StaticDataReport"
      }
    }
  ],
  "attribution": {
    "udp": "Open Waters AIS (https://openwaters.io/ais/)"
  }
}
GEThttps://ais.openwaters.io/v1/vessels/{mmsi}

Vessel details

One vessel's last known state as a GeoJSON Feature, however long ago it was heard: position, course, speed, status, and the particulars from its static data. geometry is null for a vessel whose position was never heard. first_seen is the earliest report of the vessel the network holds, from live traffic or its archive. attribution carries the credit line for the source of its last message. properties.particulars holds the vessel's particulars as registered, merged from the enrichment sources into one vocabulary, with properties.provenance naming each field's source and properties.sources carrying each source's credit, license, and its page for this vessel.

Parameters

NameLocationTypeRequiredDescription
mmsipathinteger✓The vessel's MMSI.
GEThttps://ais.openwaters.io/v1/vessels/{mmsi}/track

Vessel track

The positions the network heard from one vessel, as a GeoJSON Feature or a GPX 1.1 track. A request covers up to 366 days. Positions implying an impossible speed for the vessel (a report whose timestamp disagrees with its fix, a broken GPS) are left out of the track; the live stream and archive keep them. The geometry is a LineString for two or more positions, a Point for one, and null for none. Properties hold the range covered and arrays aligned with the coordinates: times, sog, cog, heading, and nav_status, with null where AIS marks a value not available. Without interval the track is simplified by shape: the positions that hold its path, at least 15 m off the line through those kept, with breaks naming the positions that start a stretch after the vessel went unheard, and a GPX file has a segment per stretch. With interval, when more positions match than the limit, the newest are returned and truncated is true; page back by moving to. from and to in the answer say what was covered. attribution carries the credit line for each source kind in the track, and a GPX file carries them in its metadata description.

Parameters

NameLocationTypeRequiredDescription
mmsipathinteger✓The vessel's MMSI.
fromquerystring—Start, RFC 3339. Defaults to 24 hours before to. At most 366 days before to.
toquerystring—End, RFC 3339. Defaults to now.
intervalquerystring—At most one position per interval, the first in each: whole seconds or a duration such as 5m, or 0 for every position heard. Anonymous and personal requests whose range starts more than 48 hours ago have the interval rounded up to whole minutes. A range longer than 31 days keeps one position per minute at most, and the answer reports that interval. A vessel sitting still has one position every 30 minutes for each place it sat. Without it, the track is simplified by shape instead. The answer reports the interval used.
limitqueryinteger—Most positions to return. Defaults to 1,000 and is capped by tier: 1,000 anonymous or with a personal token, 5,000 for feeder and above.
formatquerystring—geojson, the default, or gpx.
GEThttps://ais.openwaters.io/v1/vessels/tiles.json

Vessel map tiles

Live vessel positions as vector tiles, for MapLibre GL JS, OpenLayers, or any map that reads TileJSON. Point a vector source at this URL. It supplies the tile URLs, the zoom range, and the attribution. Filters and key added to this URL are passed on to every tile.

A tile shows the same vessels as /v1/vessels for an area: every vessel heard in the last 30 minutes, and vessels that were stationary when last heard up to 7 days ago. In crowded areas at low zoom, a tile keeps one vessel in each few pixels.

Tiles are snapshots, rebuilt at most every 10 seconds. Reload them on a timer to keep the map current:

map.on("load", () => {
  map.addSource("vessels", {
    type: "vector",
    url: "https://ais.openwaters.io/v1/vessels/tiles.json",
  });
  map.addLayer({
    id: "vessels",
    type: "circle",
    source: "vessels",
    "source-layer": "vessels",
    paint: { "circle-radius": 4, "circle-color": "#1565c0" },
  });
  // Reload the tiles every 15 seconds. The old ones stay on screen until the new ones arrive.
  setInterval(() => map.refreshTiles("vessels"), 15000);
});

Properties

Each vessel is a point in the vessels layer, with its MMSI as the feature id. A property is left out when the vessel has not reported it.

  • mmsi and name
  • kind: vessel, aton (aid to navigation), base (base station), or sar (search and rescue aircraft)
  • type: the ITU ship and cargo type code, or for an aton the aid-to-navigation type
  • sog in knots, and cog and heading in degrees
  • hdg: the heading, or the course over ground when there is no heading. Use it for icon-rotate.
  • nav_status, class (A or B), flag, length, and beam
  • age_s: seconds since the vessel was last heard. Use it to fade old positions.
  • source and station: who heard it

/v1/vessels/{mmsi} has the rest, such as destination and ETA.

Parameters

NameLocationTypeRequiredDescription
mmsiquerystring—Comma-separated MMSIs: only these vessels. The MMSI cap applies, as on /v1/vessels.
kindquerystring—Comma-separated: vessel, aton (aids to navigation), base (base stations), sar (search and rescue aircraft).
classquerystring—A or B, the transponder class from the position reports. A vessel whose class is not yet known is left out.
typequerystring—Comma-separated ITU ship and cargo type codes or ranges, such as 70-89 for cargo and tankers or 36,37 for sailing and pleasure craft. For an aid to navigation, the AtoN type. A vessel whose type is not yet known is type 0.
min_sogquerynumber—Only vessels at least this fast, in knots. A vessel with no known speed is left out.
max_age_movingquerystring—Oldest last report to include for a vessel whose last report was under way, that is, not stationary as /v1/vessels defines it: whole seconds, a duration such as 2h, or all. Defaults to 30 minutes for an area and to all for vessels named in mmsi.
max_agequerystring—Oldest last report to include: whole seconds, a duration such as 24h, or all. Defaults to 7 days, or to all when mmsi is given.
GEThttps://ais.openwaters.io/v1/vessels/tiles/{z}/{x}/{y}

Vessel tile by z/x/y

One vector tile, at the URL template that /v1/vessels/tiles.json points to. Most maps should use the TileJSON instead. Use this template directly only for a client that takes a tile URL rather than TileJSON. It takes the same parameters and returns the same vessels layer. Tiles have their own rate limit of 600 requests per minute per address.

Parameters

NameLocationTypeRequiredDescription
zpathinteger✓Zoom, 0–22. TileJSON advertises 14 as the maximum, and clients overzoom past it.
xpathinteger✓Column, 0 to 2^z−1.
ypathinteger✓Row, 0 to 2^z−1, from the north.
mmsiquerystring—Comma-separated MMSIs: only these vessels. The MMSI cap applies, as on /v1/vessels.
kindquerystring—Comma-separated: vessel, aton (aids to navigation), base (base stations), sar (search and rescue aircraft).
classquerystring—A or B, the transponder class from the position reports. A vessel whose class is not yet known is left out.
typequerystring—Comma-separated ITU ship and cargo type codes or ranges, such as 70-89 for cargo and tankers or 36,37 for sailing and pleasure craft. For an aid to navigation, the AtoN type. A vessel whose type is not yet known is type 0.
min_sogquerynumber—Only vessels at least this fast, in knots. A vessel with no known speed is left out.
max_age_movingquerystring—Oldest last report to include for a vessel whose last report was under way, that is, not stationary as /v1/vessels defines it: whole seconds, a duration such as 2h, or all. Defaults to 30 minutes for an area and to all for vessels named in mmsi.
max_agequerystring—Oldest last report to include: whole seconds, a duration such as 24h, or all. Defaults to 7 days, or to all when mmsi is given.

Stations

The receivers that feed the network and the areas they cover. No token needed, 120 requests per minute per address. A token can carry its own limit.

GEThttps://ais.openwaters.io/v1/stations

List stations

Every receiving station heard since the server started, with what it contributes. events counts messages the station delivered first, over the last 24 hours and 7 days. vessels is distinct MMSIs heard in the last 30 minutes and vessels_24h in the last 24 hours. vessels_exclusive_24h counts the vessels no other station heard in those 24 hours, the gap a station fills. bbox is the extent of positions heard, a rough coverage footprint. Volunteer UDP stations appear as a keyed hash, never an address.

Example Response

[
  {
    "station": "kystverket/2573010",
    "source": "kystverket",
    "events": {
      "last_24h": 41812,
      "last_7d": 280112
    },
    "duplicates": 40,
    "vessels": 61,
    "vessels_24h": 212,
    "vessels_exclusive_24h": 17,
    "positions": 1500,
    "first_seen": "2026-08-20T16:57:47Z",
    "last_seen": "2026-08-21T03:10:32Z",
    "last_age_s": 2,
    "bbox": [
      59.41,
      10.31,
      59.93,
      10.78
    ]
  },
  {
    "station": "station:ed25519:9BEqQjPRE0sZse-EbW1xUJa0K2iTBTV3TCqz1sPS7Fk",
    "source": "station:ed25519:9BEqQjPRE0sZse-EbW1xUJa0K2iTBTV3TCqz1sPS7Fk",
    "events": {
      "last_24h": 18230,
      "last_7d": 120412
    },
    "duplicates": 512,
    "vessels": 14,
    "vessels_24h": 61,
    "vessels_exclusive_24h": 9,
    "positions": 15904,
    "first_seen": "2026-08-20T16:57:47Z",
    "last_seen": "2026-08-21T03:10:31Z",
    "last_age_s": 3,
    "bbox": [
      41.38,
      -70.94,
      41.62,
      -70.51
    ],
    "name": "Quissett Harbor",
    "name_from": "operator",
    "near": "Falmouth, MA"
  }
]
GEThttps://ais.openwaters.io/v1/stations/{id}

Station details

One station's statistics plus a GeoJSON FeatureCollection of the vessels it was the latest to hear. Ids can contain slashes, such as kystverket/2573010.

Parameters

NameLocationTypeRequiredDescription
idpathstring✓Station id as reported by /v1/stations.
GEThttps://ais.openwaters.io/v1/coverage/tiles.json

Coverage map tiles

Where there are vessel positions from any source, live or historical, for the last 7 complete days, UTC, as vector tiles of H3 hexagons. Point a vector source at this URL, as for the vessel tiles. A cell the network did not hear has no feature, so draw the map beneath it as it is.

The newest day is yesterday, since today is not over. window gives the first and last day and how many days in between have coverage. Tiles change once a day.

map.addSource("coverage", { type: "vector", url: "https://ais.openwaters.io/v1/coverage/tiles.json" });
map.addLayer({
  id: "coverage",
  type: "fill",
  source: "coverage",
  "source-layer": "coverage",
  paint: { "fill-color": "#1565c0", "fill-opacity": ["/", ["get", "days"], 7] },
});

Properties

Each cell is a polygon in the coverage layer, with its H3 index as the feature id. The resolution follows the zoom: 3 below zoom 5, 4 to zoom 6, 5 to zoom 8, and 6 above.

  • vessels: distinct vessels heard in the cell a day, averaged over the window
  • days: how many days of the window the network heard the cell at all
  • stations: how many distinct stations heard the cell in the window. A volunteer station counts once whatever streams it tags, and each aggregator and government feed counts as one station, whatever receivers or paths it names, so a cell only one of them hears is as fragile as one heard by a single receiver.
GEThttps://ais.openwaters.io/v1/coverage/tiles/{z}/{x}/{y}

Coverage tile by z/x/y

One coverage tile, at the URL template that /v1/coverage/tiles.json points to. Most maps should use the TileJSON instead. Coverage tiles share the vessel tiles' rate limit of 600 requests per minute per address.

Parameters

NameLocationTypeRequiredDescription
zpathinteger✓Zoom, 0–22. TileJSON advertises 10 as the maximum, and clients overzoom past it.
xpathinteger✓Column, 0 to 2^z−1.
ypathinteger✓Row, 0 to 2^z−1, from the north.

AI assistants

Ask Claude, ChatGPT, or an agent of your own about ship traffic. The MCP server answers with live positions over the Model Context Protocol, with no sign-in.

MCPhttps://ais.openwaters.io/mcp

Tools for AI assistants over the Model Context Protocol

Ask Claude, ChatGPT, or an agent of your own about ship traffic. Open Waters AIS is an MCP (Model Context Protocol) server at https://ais.openwaters.io/mcp, with no account and no key. The assistant learns the coverage caveats and the credit line when it connects, then answers from the same live positions the rest of the API serves.

What you can ask

Track your fleet. Where is MMSI 257123000 right now? Where is Viking Cinderella going, and when does she arrive? What flag is IMO 9319466 under, and how long is she? Which of these ten vessels has not reported in the last half hour?

Monitor your waters. What is in the port of Rotterdam right now? List the tankers in the Great Belt. Is anything within five miles of 59.9 N, 10.7 E? What is around the ferry Pearl Seaways?

Check coverage. Do you cover the Gulf of Mexico? How fresh is the data around Helsinki? Which stations hear Bergen?

Every answer is the last report heard, up to 30 minutes old, and says when it was heard. The assistant answers one question at a time; for continuous updates use the WebSocket. Not for safety of navigation.

Enable it

Ask your assistant. Most agents that can edit their own configuration will do this for you. Tell Claude Code, Cursor, or whatever you use: “Add the Open Waters AIS MCP server at https://ais.openwaters.io/mcp”. Check that it chose the Streamable HTTP transport with no authentication. The manual steps below are for clients that cannot, and for anyone who wants to see what changes.

Claude Code:

claude mcp add --transport http open-waters-ais https://ais.openwaters.io/mcp

Claude.ai and Claude Desktop: Settings, Connectors, Add custom connector, paste https://ais.openwaters.io/mcp, and leave the OAuth fields empty. A free Claude plan allows one custom connector.

ChatGPT: Settings, Connectors, turn on Developer mode, then add https://ais.openwaters.io/mcp with no authentication.

Cursor: Install in Cursor, or edit ~/.cursor/mcp.json and add an entry to mcpServers:

{
  "mcpServers": {
    "open-waters-ais": {
      "type": "http",
      "url": "https://ais.openwaters.io/mcp"
    }
  }
}

VS Code: Install in VS Code, or edit .vscode/mcp.json and add an entry to servers:

{
  "servers": {
    "open-waters-ais": {
      "type": "http",
      "url": "https://ais.openwaters.io/mcp"
    }
  }
}

Gemini CLI: Edit ~/.gemini/settings.json, and add an entry to mcpServers:

{
  "mcpServers": {
    "open-waters-ais": { "httpUrl": "https://ais.openwaters.io/mcp" }
  }
}

Any other client: Streamable HTTP, stateless, plain JSON responses. One request with no session works, which is also the quickest way to see what a tool returns:

curl -X POST https://ais.openwaters.io/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_vessels_near","arguments":{"lat":59.9,"lon":10.7,"radius_nm":5}}}'

Authentication

Without a token the assistant gets the anonymous limits, which cover most questions: an area of up to 100 square degrees, about 10° by 10°, or up to 10 vessels per call, by MMSI or IMO number. A personal token raises that to 400 square degrees and 50 vessels, and the tool says so when a question exceeds the limit. Contributor and commercial tokens work the same way with their own limits; see Limits.

Send the token as an Authorization: Bearer header. Where to put it depends on the client:

  • Claude Code: add --header "Authorization: Bearer <token>" to the claude mcp add command.
  • Cursor, VS Code, and Gemini CLI: add "headers": { "Authorization": "Bearer <token>" } to the server entry.
  • Claude.ai, Claude Desktop, and ChatGPT: custom connectors take no headers, so these run at the anonymous limits.

Never put the token in the URL. A credential in a query string ends up in logs and browser history, and the MCP specification forbids it.

Each tool call is one request against the 120-per-minute HTTP limit, and a call returns at most 200 rows, 50 unless asked.

Tools

Five read-only tools, so a client that asks before running write tools never prompts for these.

Tool Answers
get_vessels Position and particulars for a list of MMSIs or IMO numbers: destination, ETA, draught, dimensions, call sign, flag, and which identifiers matched nothing in the last 30 minutes.
find_vessels_in_area What is inside a bounding box, newest report first, with optional kind and ship-type filters.
find_vessels_near What is within a radius of up to 50 NM of a point or of another vessel, nearest first, with distance and bearing.
search_vessels_by_name Vessels whose name contains the text, to turn a name into an MMSI.
get_coverage Which sources and stations are delivering, how fresh they are, and whether a box has any coverage at all.

Every row carries decoded labels (type_name, nav_status_name) beside the codes, seen and age_s for freshness, the flag from the MMSI, the source and station it came from, and, once the vessel’s static data has been heard, its IMO number, call sign, destination, ETA, draught, length, and beam. The flag is present when the MMSI’s maritime identification digits are known. The area, radius, and name searches take a flag filter, an ISO 3166-1 alpha-2 code such as NO or MH. Every result carries an attribution map with the credit line per source, and says when it cut the list so the assistant can narrow the question.

Contributing data

Send what your receiver hears. Everything you feed is credited to your station, and 1,000 messages a day earns a personal token the feeder tier.

MQTT is the realtime path: AIS-catcher publishes each message as it is decoded, credited to your token's station. Already on the WebSocket stream? It works in both directions: send publish frames with any token, on the same connection you subscribe with.

POSThttps://ais.openwaters.io/v1/receive

HTTP

Contributing over HTTP with AIS-catcher's -H output or your own client:

AIS-catcher -H https://ais.openwaters.io/v1/receive USERPWD x:<token> GZIP on INTERVAL 15

The body is AIS-catcher's jsonaiscatcher envelope or plain newline-separated NMEA, optionally gzipped, up to 1 MB. Needs a token, whose sub becomes the station name. 600 posts per minute per station. Feeding data is acceptance of the contributor agreement, linked from every response's Link: rel="terms-of-service" header.

Request Body

application/json

AIS-catcher jsonaiscatcher envelope.

FieldTypeRequiredDescription
msgsarray—
{
  "msgs": [
    {
      "nmea": [
        "!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23"
      ],
      "rxtime": "20260820111900",
      "channel": "A"
    }
  ]
}

text/plain

Newline-separated NMEA sentences, TAG blocks allowed.

!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23
MQTTwss://ais.openwaters.io/v1/stream

Realtime NMEA ingest over MQTT on a WebSocket

The realtime path for a receiver. AIS-catcher’s MQTT output publishes each message as it is decoded, where its HTTP output batches on an interval, so this is how a station gets its data in with no delay and with credit. Needs a token.

AIS-catcher -Q wssmqtt://x:<token>@ais.openwaters.io:443/v1/stream MSGFORMAT NMEA

The port is required: AIS-catcher does not default it for wssmqtt://. MSGFORMAT NMEA_TAG also works and carries the receive time in a TAG block.

The same address as the JSON stream: a socket that negotiates the mqtt WebSocket subprotocol (binary frames) gets a receive-only MQTT 3.1.1 session instead of JSON frames. Any MQTT client that speaks WebSocket can publish to it:

  • CONNECT carries the token as the password, or the socket carries it as ?key= or a header like any /v1/stream connection. The username and client id are ignored. CONNACK answers 0x04 for a token that does not verify and 0x05 for one that may not publish or is used from outside its cidr, then the connection closes.
  • PUBLISH payloads are newline-separated NMEA sentences, TAG blocks welcome, on any topic. QoS 0 is accepted silently, QoS 1 gets a PUBACK, and QoS 2 completes the handshake. Duplicate deliveries dedupe like any repeat.
  • SUBSCRIBE is refused with 0x80 per filter. Read from the streams instead.
  • PINGREQ is answered. A keep-alive is honored when set; with none, the WebSocket ping keeps the session alive.
  • Connects are limited per token, 20 a minute, applied once CONNECT has named the token. Over the limit, CONNACK answers 0x03 (server unavailable) and the connection closes.

Your station is your token’s sub, and your messages appear with source: station:<sub> on the map, in the streams, and at /v1/stations, the same station as the HTTP and WebSocket paths. 6,000 sentences a minute per token, 1 MB per packet. Publishing is acceptance of the contributor agreement, linked from the upgrade response’s Link: rel="terms-of-service" header.

UDPudp.ais.openwaters.io:10110

Raw NMEA ingest

Point your receiver at udp.ais.openwaters.io port 10110. No token, no sign-up. AIS-catcher, rtl-ais, and most other decoders can send UDP out of the box:

AIS-catcher -u udp.ais.openwaters.io 10110

Send newline-separated NMEA (!AIVDM, !AIVDO, !BSVDM, and friends, TAG blocks welcome, lines up to 4 KB), at up to 500 sentences a second. Your station appears as udp:<hash> (never your address), or as mmsi:<n> once it has sent its own !AIVDO position.

Want credit for what you contribute? Create a personal token with bind_ip: true from the same address. The station then counts toward that token’s contributor tier, which raises your stream limits and opens the raw NMEA feed.

UDP is unauthenticated, so it is the lowest-trust path. Your messages show on the map and in the streams with their source, but they are forwarded to AISHub and the raw NMEA feed only while a trusted source has heard the same vessel within the last hour. As with every source, a position more than 10 nm from the vessel’s last known position that also implies more than 120 knots is archived rather than shown, and a position of exactly (0, 0) is treated as “not available” rather than a fix.

Status

For status pages and monitors. No token needed.

GEThttps://ais.openwaters.io/v1/stats

Stats

A summary for status pages and growth tracking: stations, vessels, event rates, client counts, and a breakdown by source kind. Vessel counts come from the vessel record, which holds every vessel any archive has heard. The other counts are rolling 24-hour and 7-day windows that survive restarts, never since-start totals.

Example Response

{
  "time": "2026-08-21T13:40:12Z",
  "stations": {
    "total": 14,
    "active": 11,
    "by_source": {
      "kystverket": 1,
      "digitraffic": 1,
      "station": 5,
      "udp": 7
    }
  },
  "vessels": {
    "total": 332255,
    "active": 4812,
    "with_position": 4790,
    "by_kind": {
      "vessel": 4701,
      "aton": 88,
      "base": 19,
      "sar": 4
    },
    "last_24h": 96267,
    "last_7d": 176687,
    "last_30d": 312480,
    "new": {
      "last_24h": 2115,
      "last_7d": 19485,
      "last_30d": 101240
    }
  },
  "events": {
    "per_second": 212.4,
    "last_24h": 18230411,
    "last_7d": 121004312,
    "duplicates": {
      "last_24h": 2210560,
      "last_7d": 15320011
    }
  },
  "clients": {
    "streams": 9,
    "streams_opened": {
      "last_24h": 410,
      "last_7d": 2822
    },
    "requests": {
      "last_24h": 28310,
      "last_7d": 190412
    }
  },
  "sources": {
    "kystverket": {
      "events": {
        "last_24h": 9120033,
        "last_7d": 61233190
      },
      "last_age_s": 0,
      "vessels": 2411,
      "vessels_exclusive": 180,
      "delay": {
        "n": 512,
        "p50": 0.6,
        "p99": 1.1
      }
    }
  }
}
GEThttps://ais.openwaters.io/health

Health

ok while data is reaching subscribers. 503 after two minutes of nothing. One silent source is not an outage while the others keep the stream flowing.

Example Response

ok
GEThttps://ais.openwaters.io/openapi.json

OpenAPI specification

Limits

To keep the service sustainable, we have limits on how much data you can request. The limits are based on the token you use to access the service. If you read without a token, you are on the anonymous tier. See Authentication.

Contributor is a personal token whose stations delivered at least 1,000 messages in the last 24 hours. It is treated as the contributor tier for as long as they keep contributing. The tier lapses when the station stops and comes back when it resumes.

Need more? Email us with what you are building.

Anonymous Personal Contributor Partner / admin
Concurrent streams 2 per address 2 5 as issued
Messages/s per stream (excess thinned) 20 50 200 as issued
Subscribed area (sq °) 100 400 unlimited as issued
Vessels followed by MMSI per stream 10 50 200 as issued
Raw NMEA feed no no yes yes
Publish no yes yes yes
  • 32 concurrent streams per network address across all tokens, and 20 WebSocket connects per minute per address.
  • 120 requests per minute per address on the HTTP endpoints, and 10 per minute on POST /v1/keys and in-band register.
  • 600 requests per minute per address on /v1/vessels/tiles/{z}/{x}/{y}, apart from the 120. A map view is about 20 tiles. /v1/vessels/tiles.json counts toward the 120. The subscribed area limit does not apply to tiles.
  • 500 UDP sentences a second per source address.
  • A client that falls 1,024 events behind is disconnected.
  • Publishing: 6,000 sentences a minute per token on MQTT and the WebSocket (1,000 per WebSocket frame, 1 MB per MQTT packet), 600 posts a minute and 1 MB per post on /v1/receive.