/v0 — Compatibility with aisstream.io

wss://stream.pelyr.com/v0/stream reproduces the protocol of the discontinued provider aisstream.io. The goal is explicit: the switch should be a search-and-replace in the client — the same frame shape, the same field names, the same mixed case in MetaData, the same error wording.

Anyone who wants more than a reproduction takes /v1: named bounding-box fields instead of [lat,lon], real close codes, confirmation frames, a heartbeat with a loss counter, null where a value is missing — and never silent throttling.

The most important thing first: /v0 cannot be verified

There is no running reference server for a byte-for-byte comparison. Everything on this page is built against the aisstream.io documentation and archived examples. The header of the golden tests therefore states, verbatim:

This test proves the stability of our output, not compatibility with the original.

Every response from this endpoint carries the header X-Pelyr-Compat: v0-partial. This page is the complete list of what "partial" means.

Connection and subscription

/v0 here
Path/v0/stream
Subprotocolsnone
Client's first text framewithin 3 s, otherwise close
Key fieldAPIKey and Apikey — both accepted
Key in the Authorization headerno — frame only. On /v1 it is the primary path
Changing the key in a later framerejected — scope, connection limit and quota are tied to the first
Bounding boxes[[[lat1,lon1],[lat2,lon2]]]latitude FIRST, corners normalised to min/max
Bounding boxes per subscription50
FiltersShipMMSIstrings of 9 characters each, at most 50
FilterMessageTypesnames from the original, see type table
Data frameMessage with exactly one key, plus MessageType and MetaData
Errorsexclusively {"error":"..."}
Close after an error1000, explicitly no 4xxx — but not every error frame closes, see below

Why both spellings of the key? The original's documentation contradicts itself: one place says APIKey, another Apikey. We accept both rather than guess. The wrong choice would have meant a valid key gets rejected — the user would see Api Key Is Not Valid despite a correct key, with no way to tell why. If both fields carry different values in one frame, it is rejected: which one was meant cannot be decided.

Why no 4xxx close codes? An aisstream client does not know them and logs them as a network error. On an invalid key you therefore get exactly

{"error":"Api Key Is Not Valid"}

character for character, followed by close 1000. On /v1 there is a code, a fatal flag and a descriptive close code for this instead.

A key that exists but was not sold for the stream is the exception to that wording. It gets the same error frame and the same close 1000, but a different sentence: the key is not invalid — it grants access to the HTTPS API, just not to this one. Telling that user their key is invalid would only make them issue a new one that cannot stream either.

Message types

The table lists only the types actually reachable. For the collector this depends on the feed: some collector feeds assign the type themselves and therefore carry only {1, 5}, because their upstream does not pass the original AIS type through. Others pass the real type through and carry {1, 2, 3, 5, 18, 19, 24} — one of those also carries 14, another also 27. The receiver carries {1, 2, 3, 5, 18, 19, 24}, counted against the 906-record corpus, which contains receiver payloads only.

AIS typeOriginal's frame nameDelivered by /v0
1, 2, 3PositionReportyes
5ShipStaticDatayes
18StandardClassBPositionReportyes
19ExtendedClassBPositionReportyes
24StaticDataReportyes — see below
4, 9, 14, 21, 27 and all othersBaseStationReport, AidToNavigationReport, …no — no frame is built, such messages are dropped silently

Type 24 comes in two parts. The original models this with PartNumber, ReportA and ReportB; our flat contract payload does not carry that split as a field of its own. It is therefore derived, not guessed: part A carries the ship name only, part B the ship type and call sign only — the inverse is unambiguous, and in the 906-record corpus above — receiver payloads — no type 24 record carries both at once. A message carrying none of the three fields cannot be assigned to a part and is dropped.

Only the intended sub-object appears in the frame. The original sends both, including the one not meant — where it then reads ShipType: 0 and CallSign: "". We leave it out, because 0 is a valid ship type, and an empty ReportB sent along would claim the vessel had transmitted part B. A Go client reads a missing ReportB as a zero struct with Valid: false — the same statement, without the claim; a Python client gets the KeyError this protocol considers the more honest error.

FixType and the three VendorID fields are absent: the contract payload has no field for any of them. The same omission rule as for Raim.

Dimension is a different case since the contract gained vessel dimensions: the data now exists, and /v0 still does not send it. That is a decision, not a limitation — this endpoint exists for the migration, and vessel dimensions are reachable on /v1 in fields:"full".

A filter that names exclusively undeliverable types is rejected. That is the only feedback /v0 has — and it also prevents a data leak: an empty type mask means "all" in the filter, so "AtoN only" would otherwise become "everything".

receiver does not apply on /v0 (see difference 9), so only feed data arrives. Which frames come out of that depends on the feed: some feeds label every position type 1 and every static message type 5, so from them you get only PositionReport and ShipStaticData. Others pass the real type through, so from them you also get StandardClassBPositionReport, ExtendedClassBPositionReport and StaticDataReport — in Norwegian waters type 24 is the most common static message, and for Class B vessels it is the only route to a name.

ShipStaticData and bounding boxes are mutually exclusive. A static message carries no position in the contract, and a message without a position cannot satisfy a box. A subscription without BoundingBoxes receives static messages; as soon as a box is set, it receives position reports only. The original solved this from its own vessel state — it knew where a ship last stood and attributed its static message to the box. This gateway holds no vessel state (see difference 1) and cannot make that attribution.

When a limit is hit

/v0 has the same response for every limit, because there is no other: an {"error":"…"} with explanatory text, then close 1000. On /v1 each one has its own 4xxx code that a client can evaluate.

Reason/v1/v0
Monthly volume exhausted at connectHTTP 403 before the upgradeerror + close 1000 after the upgrade — the key is not known before that
Monthly volume exhausted during the sessionClose 4010error + close 1000
Client reads too slowly (> 1000 dropped frames in 60 s)error + close 4008error + close 1000
Output rate exceeded continuously for 60 snotice{throttled} ongoing, then close 4009error + close 1000, without an ongoing notice
Key revoked or scope withdrawnClose 4011 / 4002error with the wording Api Key Is Not Valid + close 1000
Service shuts downerror + close 4012, staggerederror with the same reason text + close 1000, staggered
Five frames too fast in a rowClose 4006error + close 1000

Not every error frame is fatal. /v0 has exactly one frame type for everything that is not data, so the same {"error":"…"} also carries advisories. Three of them leave the connection standing: a subscription that matched nothing in the recent traffic (the coverage notice, difference 10), a frame that arrived less than a second after the previous one, and the first unreadable or invalid frame. The connection ends on the second protocol error in a row, on the fifth too-fast frame in a row, and on the limits in the table above. A client that treats every {"error":…} as terminal and closes by itself throws away a healthy connection on the most common of the three — the coverage notice, which arrives right after a subscribe whose box has no traffic in it.

Why no ongoing throttling notice? /v0 has neither a heartbeat that could carry a loss counter, nor a frame type an aisstream client is sure to read as not fatal — the three advisories above run exactly that risk, which is why there are three of them and not a running commentary. Loss below the threshold therefore stays unnamed on /v0 — the backlog still becomes visible, because the connection names it at the end instead of letting it run on silently (difference 11). Anyone who wants to see the losses continuously takes /v1.

There is no welcome frame. The first frame a /v0 client sees is a message or an error. On /v1 the welcome carries the limits, the sources and their licence texts. Here the limits are in this document; the attribution texts are in the Terms of Use and on the website (difference 9).

Data frame fields

What is sent is what the AisPosition v1 contract carries. Everything else is missing — it does not appear with a null value, it does not appear at all.

PositionReport (type 1, 2, 3)

FieldSource
Cogcog, 0 if null
Latitude, Longitudelat, lon as raw JSON values; missing without a usable position
MessageIDmsg_type
NavigationalStatusnav_status, missing when null
PositionAccuracypos_accuracy, missing when null
RateOfTurnrot, missing when null
Sogsog, 0 if null
TrueHeadingheading, missing when null
UserIDmmsi
Validalways true

StandardClassBPositionReport (18) carries the same fields withoutNavigationalStatus and RateOfTurn — the original's Class B frame does not have them. ExtendedClassBPositionReport (19) additionally carries Name and Type, because type 19 carries them in the radio message itself.

ShipStaticData (type 5)

FieldSource
CallSigncallsign
Destinationdestination
Etaeta, converted to {"Month":…,"Day":…,"Hour":…,"Minute":…}
ImoNumberimo
MaximumStaticDraughtdraught (metres, not decimetres)
MessageIDmsg_type
Nameshipname
Typeshiptype
UserIDmmsi
Validalways true

The contract carries the ETA as "MM-DDTHH:MM" — lossless, because AIS does not transmit a year. The conversion into the four numbers is unambiguous; adding a year would be an interpretation and is left out. An ETA that does not match the contract form is omitted, not guessed.

MetaData

FieldValue
MMSImmsi
ShipNamealways "" — see difference 1
latitude, longituderaw lat/lon; missing without a usable position
time_utcingest_ts in the format 2026-08-13 10:00:00.500000 +0000 UTC

The mixed case is the original's and intentional.

time_utc carries Go's time.String() form, but forced to six decimal places: Go's String() trims trailing zeros, and a client with a fixed field width would break on .5 +0000 UTC instead of .500000 +0000 UTC. It is truncated, not rounded. An unreadable ingest_ts leaves the field out, rather than claiming year 1.

We do not send MMSI_String: in archived examples it holds a number despite the name, and which type was meant cannot be determined without a reference server. A guessed type would be worse than a missing field — the same rule as in difference 3 applies.

After an upstream outage, the gap stays a gap

This is not a difference from the original protocol but a property of the service, so it stands ahead of the numbered list.

If our upstream is unreachable for more than a minute, we discard the backlog rather than replaying it at you as live data. You will see a gap in the stream, and time_utc on the frames after it jumps forward.

This is deliberate. A replayed backlog arriving at full speed looks like live traffic, puts every vessel at a position it left minutes ago, and is indistinguishable from the real thing at the client. A gap is honest and recoverable — the next frame for each vessel carries its current position.

The eleven differences

1. MetaData.ShipName is always empty

The original fills the name from its own vessel state; a position report carries none. This gateway holds no vessel state and delivers "".

The most serious single difference, because many clients key off ShipName. Even a static message that does carry the name does not fill it: a field that is populated in a small minority of frames and empty in the rest looks to the client like a working field with gaps — and that is harder to spot than one that is empty throughout.

The name is in the static message (ShipStaticData.Name) and on /v1 in the field shipname.

2. We never deliver 20 of the original's 25 types

AisPosition v1 is a flat union of scalar fields without type-specific raw bit fields. What cannot be derived from it does not exist. The type table above is complete; a type that cannot be mapped means no frame at all is sent — better none than an invented one.

3. Missing fields are omitted, not filled with zero values

We do not have: RepeatIndicator, Timestamp, SpecialManoeuvreIndicator, Spare, Raim, CommunicationState, AisVersion, Dte, FixType — and MMSI_String.

Dimension{A,B,C,D} is not in that list any more: the contract carries the four offsets. /v0 does not send them, deliberately — see difference 1.

Sending "Raim":false would be a claim about the radio link that we never made. For a maritime data product with the disclaimer "not a navigation aid", that is the one mistake you must not make. The client's KeyError is the more honest error message.

The same applies to contract fields that are null in an individual case: NavigationalStatus, RateOfTurn, PositionAccuracy, TrueHeading and the coordinates are then missing, rather than claiming a 0. No /v0 frame ever contains a null — the original's format does not have one.

Anyone who needs a fixed field list takes the position profile on /v1: all eight fields are always present there, and a missing value is explicitly null.

4. Valid is always true

For collector data the authority's decoder made this decision, not ours. We would have no source of our own for a validity claim; sending false would be a claim about someone else's work.

5. time_utc means something else

For the original it is the receive time at their server; for us it is ingest_ts — the moment this platform accepted the message. The difference is the transit time through the feed and is typically seconds.

On /v1 both stand cleanly side by side: rx_ts is the receive time at the antenna, ingest_ts the acceptance.

6. The Sog/Cog sentinels are lost

AIS encodes "not available" as 102.3 kn and 360 degrees respectively. Our contract turns that into null, and /v0 has no null in a floatwe send 0.

A /v0 client can no longer distinguish "not available" from "lying still". This is the only place where /v0 invents a value instead of omitting it; unavoidable, because the original's field manages without null. On /v1, null is preserved.

7. msg_type from the collector source is assigned, not original

Some collector feeds do not pass through the original AIS type. Such a feed therefore carries an assigned type: 1 for a position report, 5 for metadata. A Class B position report thus appears as PositionReport (type 1), not as StandardClassBPositionReport.

This also affects the filter: FilterMessageTypes: ["StandardClassBPositionReport"] delivers nothing from a feed like that.

8. The antimeridian cannot be expressed

The original normalises a box's corners with min/max, and so do we. 170 and -175 therefore become -175..170half the world instead of a narrow strip across the date line.

/v1 can express this case: there, west > east explicitly means "across 180 degrees".

9. Attribution cannot travel with the data

The original's frame has no licence field. On /v0, attribution therefore cannot travel with each individual message.

/v0 therefore delivers collector data exclusively, and attribution is in the Terms of Use and on the website instead of in the frame. Anyone who needs the licence per message takes /v1 — there every data frame carries a license field, and the welcome frame maps that identifier to its attribution text in machine-readable form. A real gap, and it is stated as one here.

10. Coverage differs, and it is no longer a Nordic-only feed

aisstream.io was worldwide. So, today, is a large part of what we carry: the global share is sampled, at roughly one position per vessel per minute. The Nordic feeds are continuous but regional. What differs is not the map but the update rate.

The other half of the same subject: where a subscription matches nothing, /v0says so, and the original would have stayed silent. After a subscribe whose filter matched none of the recent traffic, this endpoint sends a single {"error":"…"} whose text says exactly that — and keeps the connection open. It is the one advisory /v0 has, it is not fatal, and it exists because silence, not an error, is the most common disappointment when moving over.

11. Not reproduced: silent throttling and Metadata

The original throttled a slow client silently. We do not — not all the way to the end, at least: on /v0 the backlog becomes visible when the connection ends with an error frame that names it, instead of the client never learning at all. Losses below the threshold do stay unnamed on /v0, as above: there is no frame type that could report them without an aisstream client reading it as fatal. On /v1 the heartbeat names them continuously.

We also send only MetaData — the spelling Metadata that also appears in some archived examples does not exist here.

Where /v0 is more lenient than the original

These points are not differences in the sense above: they accept more than the original assumed, and cannot break a running client.

  • Both spellings of the key field (APIKey, Apikey).
  • Missing or empty BoundingBoxes mean "worldwide" instead of a rejection.
  • An explicit null in BoundingBoxes, FiltersShipMMSI or FilterMessageTypes counts as a missing field, i.e. "no filter".
  • Unknown fields in the subscribe frame are ignored.

The key lives in the message body

On /v0 the subscribe frame carries the APIKey — unlike /v1, where it lives in the Authorization header. A full-text log of a rejected subscribe would therefore be a plaintext leak by design.

The debug mode that logs rejected frames therefore goes exclusively through a masking step. It replaces the value of every key-like field — including in nested objects — with "***", and for an unreadable frame it does not echo it at all, only its length. There is no fallback to "log it raw instead": unreadable JSON is the most common rejected frame, and that is exactly where the leak would sit.

Limits

Limit/v0/v1
Bounding boxes per subscription5050
Bounding boxes per connection50 (one subscription)80 (across all subscriptions)
MMSI per subscription50500
Subscriptions per connection1 (each frame replaces everything)4, individually replaceable
Deadline for the first frame3 s5 s
Minimum gap between two subscribes1 s5 s

All remaining limits apply equally to both endpoints: connections per account, output rate and monthly volume. The values in force for a given key are named on /v1 by the welcome frame in limits.output_bytes_per_second and limits.quota_bytes_per_month; a 0 there explicitly means no limit. /v0 has no welcome frame and therefore cannot announce them — if you need to know, connect once on /v1 and read them there. The two rows about them in the error table above describe what happens on /v0 once one of the limits bites.