/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 |
| Subprotocols | none |
| Client's first text frame | within 3 s, otherwise close |
| Key field | APIKey and Apikey — both accepted |
Key in the Authorization header | no — frame only. On /v1 it is the primary path |
| Changing the key in a later frame | rejected — 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 subscription | 50 |
FiltersShipMMSI | strings of 9 characters each, at most 50 |
FilterMessageTypes | names from the original, see type table |
| Data frame | Message with exactly one key, plus MessageType and MetaData |
| Errors | exclusively {"error":"..."} |
| Close after an error | 1000, 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 type | Original's frame name | Delivered by /v0 |
|---|---|---|
| 1, 2, 3 | PositionReport | yes |
| 5 | ShipStaticData | yes |
| 18 | StandardClassBPositionReport | yes |
| 19 | ExtendedClassBPositionReport | yes |
| 24 | StaticDataReport | yes — see below |
| 4, 9, 14, 21, 27 and all others | BaseStationReport, 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 connect | HTTP 403 before the upgrade | error + close 1000 after the upgrade — the key is not known before that |
| Monthly volume exhausted during the session | Close 4010 | error + close 1000 |
| Client reads too slowly (> 1000 dropped frames in 60 s) | error + close 4008 | error + close 1000 |
| Output rate exceeded continuously for 60 s | notice{throttled} ongoing, then close 4009 | error + close 1000, without an ongoing notice |
| Key revoked or scope withdrawn | Close 4011 / 4002 | error with the wording Api Key Is Not Valid + close 1000 |
| Service shuts down | error + close 4012, staggered | error with the same reason text + close 1000, staggered |
| Five frames too fast in a row | Close 4006 | error + 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)
| Field | Source |
|---|---|
Cog | cog, 0 if null |
Latitude, Longitude | lat, lon as raw JSON values; missing without a usable position |
MessageID | msg_type |
NavigationalStatus | nav_status, missing when null |
PositionAccuracy | pos_accuracy, missing when null |
RateOfTurn | rot, missing when null |
Sog | sog, 0 if null |
TrueHeading | heading, missing when null |
UserID | mmsi |
Valid | always 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)
| Field | Source |
|---|---|
CallSign | callsign |
Destination | destination |
Eta | eta, converted to {"Month":…,"Day":…,"Hour":…,"Minute":…} |
ImoNumber | imo |
MaximumStaticDraught | draught (metres, not decimetres) |
MessageID | msg_type |
Name | shipname |
Type | shiptype |
UserID | mmsi |
Valid | always 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
| Field | Value |
|---|---|
MMSI | mmsi |
ShipName | always "" — see difference 1 |
latitude, longitude | raw lat/lon; missing without a usable position |
time_utc | ingest_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 float —
we 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..170 — half 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
BoundingBoxesmean "worldwide" instead of a rejection. - An explicit
nullinBoundingBoxes,FiltersShipMMSIorFilterMessageTypescounts 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 subscription | 50 | 50 |
| Bounding boxes per connection | 50 (one subscription) | 80 (across all subscriptions) |
| MMSI per subscription | 50 | 500 |
| Subscriptions per connection | 1 (each frame replaces everything) | 4, individually replaceable |
| Deadline for the first frame | 3 s | 5 s |
| Minimum gap between two subscribes | 1 s | 5 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.