/v1 — the Pelyr OPEN-AIS protocol

wss://stream.pelyr.com/v1/stream delivers AIS vessel reports as a live stream. This is the native protocol. Anyone migrating an existing aisstream.io client will find a compatible replica at /v0 — it exists for the migration, not for new clients.

Read section 11 before you build against this page. What this service carries and what it does not is the yes/no question for an integration, and no protocol detail below changes the answer.

Every frame the service sends on this page is generated byte for byte, the way it actually goes out. Those examples are not hand-written — they come from the code: an automated test builds each frame with the same code that builds it in production, and compares it character by character with what stands here. If a line drifts, the test turns red. The frames a client sends, and the example client in section 12, are hand-written illustrations.


1 · Connecting

wss://stream.pelyr.com/v1/stream

The key travels one of two ways, never in the URL:

Authorization: Bearer pk_...

or, for browsers — the JS WebSocket API has no way to set custom headers:

new WebSocket("wss://stream.pelyr.com/v1/stream",
              ["pelyr.v1", "pelyr-key." + key])

A key in the query string is rejected, with HTTP 400 and the code key_in_query — not accepted quietly. Query strings end up in proxy logs and travel onward through the Referer header. A key that has ever sat there is burned, and no one notices.

2 · The flow, in one picture

Client                                   Server
  │  ─── WebSocket upgrade (+ key) ────────▶
  │  ◀────────────────── welcome ──────────   immediately, unprompted
  │
  │  ─── subscribe ────────────────────────▶   within 5 s, otherwise Close 4003
  │  ◀────────────────── subscribed ───────
  │
  │  ◀────────────────── position ─────────    continuously from here on
  │  ◀────────────────── position ─────────
  │  ◀────────────────── heartbeat ────────    every 20 s, even in silence
  │
  │  ─── ping ─────────────────────────────▶   optional
  │  ◀────────────────── pong ─────────────

The welcome frame arrives unprompted and first. It carries everything a client would otherwise have to guess — limits, its own scope, the deadline for the first subscribe, and the attribution duties of the sources.

ping is capped at one answer per second. A ping that arrives less than a second after the previous one goes unanswered.


3 · welcome

{"type":"welcome","protocol":"pelyr.v1","server_time":"2026-08-14T15:04:05Z","subscribe_deadline_ms":5000,"bbox_fields":["west","south","east","north"],"key":{"prefix":"pk_","scope":"collector"},"limits":{"subscriptions":4,"bbox_per_subscription":50,"bbox_per_connection":80,"mmsi_per_subscription":500,"output_bytes_per_second":0,"quota_bytes_per_month":0,"quota_bytes_used":6442450944,"quota_resets_at":"2026-09-01T00:00:00Z"},"sources":[{"id":"0","license":"NOASSERTION","source":"","attribution":""},{"id":"1","license":"LicenseRef-Pelyr-1.1","source":"Pelyr","attribution":"AIS data from Pelyr (pelyr.com), Pelyr Data Licence 1.1"},{"id":"100","license":"CC-BY-4.0","source":"Digitraffic Finnland","attribution":"Traffic data from Fintraffic / digitraffic.fi, licence CC BY 4.0"},{"id":"101","license":"NLOD","source":"BarentsWatch Live AIS","attribution":"Data delivered by BarentsWatch"}],"notes":["msg_type for source 'digitraffic-fi' is assigned by the feed (1 = position, 5 = static) and is not the original AIS message type."],"docs":"https://pelyr.com/stream-api/","terms":"https://pelyr.com/stream-terms/"}
FieldMeaning
subscribe_deadline_msDeadline for the first subscribe. Let it pass and Close 4003 follows
bbox_fieldsThe edge names, spelled out. See section 5
key.scopeWhat this connection may use: collector or collector_receiver. It is the scope in force at the moment the connection opened — the account's own grant, or a platform-wide grant that covers every account. It can be widened while you are connected, and this field is not updated when that happens; see section 4
limits.*The limits of this connection, see section 9. A 0 means no limit — not "zero"
sources[]The attribution duty, machine-readable — per id, not per source
notesOperational remarks, present only when there is something to say. See notes[] — read this before you debug in section 4

Read the numbers in limits, do not hard-code them, and read a 0 as no limit rather than as zero. Both output_bytes_per_second and quota_bytes_per_month are 0 today: there is no output rate limit and no monthly volume limit, and none is planned. The quota_bytes_used and quota_resets_at counters still run, so you can see your own consumption — nothing is enforced against it.

Every data frame carries an id in its license field, not the licence text itself — resolve it against sources[].id. The long credit text lives only in welcome.

The list names ids, not licences. Every feed from 100 upward gets its own id and its own entry — source names that one feed, even when its licence text happens to match another feed's word for word. Two feeds share an id only when they run under one collective licence; source then names that collective, not either individual feed. So a frame does tell you which feed delivered it, whenever that feed has an id of its own — and tells you only the obligation when it runs under a collective entry.

Two ids are reserved and appear in every welcome, ahead of any feed-specific entry: 0 — no licence on file for that message's source, nothing to attribute — {"id":"0","license":"NOASSERTION","source":"","attribution":""}; and 1 — the data runs under Pelyr's own licence — {"id":"1","license":"LicenseRef-Pelyr-1.1","source":"Pelyr","attribution":"AIS data from Pelyr (pelyr.com), Pelyr Data Licence 1.1"}. A client built only against a hand-picked example is unprepared for either — 0 in particular carries an empty attribution.

The identifier LicenseRef-Pelyr-1.1 covers data Pelyr obtains directly, as distinct from data it obtains from third-party feeds. Its entry is always present in sources[], even on a connection where no such message arrives. That licence is more restrictive than the open-data licences of the other entries: it permits commercial use and requires attribution, but does not permit the data to be passed on. The text is at https://pelyr.com/pelyr-data-license.

The identifier carries its version, and that is deliberate — section 8.2 of the licence ties data to the version it was delivered under, and this field is the only place that version reaches you. A new version of the licence gets a new identifier; this one is never reinterpreted.

The shape, so you can type it against something. Every entry has exactly four fields — id, license, source, attribution — always in that order and always strings. source and attribution can be the empty string; none of the four is ever null and none is ever absent. Entries are ordered by id, numerically ascending.

The id is a decimal string and will stay a string, never a number. "0" and "1" are reserved and appear in every welcome; feeds are numbered from "100" upward, and the band 299 is reserved and unused. A data frame's license is always one of these ids.

The id is the identifier; license, source and attribution are display strings. Do not parse them, do not match on them, do not switch on them — they are free text and may be reworded at any time. Everything you decide, you decide on the id.

An unknown license id means reconnect. sources[] is fixed for the lifetime of the connection — built once in welcome, never refreshed while it stays open, and connections can stand for days. If a data frame's license id is not among your own welcome.sources[], the source list has changed since you connected: treat the attribution as unknown, and reconnect to pick up the current one.


4 · subscribe

{"type":"subscribe","id":"ostsee","bbox":[{"west":18,"south":59,"east":26,"north":66}],"fields":"position"}

Response:

{"type":"subscribed","id":"ostsee","effective":{"bbox":[{"west":18,"south":59,"east":26,"north":66}],"fields":"position","mmsi_count":0}}
FieldMeaning
idThe id from your subscribe, echoed back
effectiveThe selection that actually applies — see below
notesPresent only when there is something to say. See notes[] — read this before you debug

notes[] — read this before you debug

The subscribed frame may carry a notes array. It is the only place where the service tells you that it accepted your subscription but will not do what you meant. There is no error, because nothing was invalid — the subscription simply cannot deliver what the combination of fields implies.

{"type":"subscribed","id":"static","effective":{"bbox":[{"west":18,"south":59,"east":26,"north":66}],"fields":"full","mmsi_count":0},"notes":["include_positionless has no effect while a bbox is set"]}

The notes you can currently receive:

NoteWhat it means
include_positionless has no effect while a bbox is setA message without coordinates cannot satisfy a bounding box. Types 5 and 24 will never arrive on this subscription. Use a second subscription with no bbox
bbox[0] crosses the antimeridianwest > east is read as the statement "across the 180th meridian", not as a mistake — see section 5. Because a swapped coordinate pair looks exactly like one, this note says so explicitly instead of leaving you to find half of Siberia in your data
unknown field 'x' was ignoredA field subscribe doesn't recognise is accepted and reported, not silently dropped
unknown field 'resume' was ignored; there is no resumption, a reconnect starts from nowSpecial case: the id format looks like it invites resumption. It doesn't exist

The list grows. Treat an unknown note as informational and log it.

The welcome frame carries the same field for operational remarks — its own example in section 3 already shows one in use.

notes[] is not notice. The two answer the same complaint — "my subscription delivers nothing" — from opposite sides. A note rides on the subscribed frame and is about your filter as the server read it: it is settled the moment the subscription is accepted and never changes afterwards. A notice is a frame of its own and is about the data: what the stream is, or is not, doing for a subscription that has already been accepted. Section 9 lists the notice codes.

effective is the selection that actually applies, not the one that was requested. A typo in bbox or msg_types shows up here in the first frame back, instead of as data that simply never arrives. effective does not name a source selection — which sources reach this connection is not a per-subscription choice, see Every subscribe field below.

msg_types appears in effective as soon as a type filter applies — sorted ascending, e.g. "msg_types":[1,2,3,18,19]. Without a filter the field is absent entirely; listing all 27 numbers just to say "no restriction" would cost bytes on every subscribe for no information. A typo within the valid range — 15 instead of 5 — shows up in the first frame back instead of as data that simply never arrives.

Every subscribe field

FieldDefaultMeaning
idName of the subscription. A second subscribe with the same id replaces the previous one
bboxallUp to 50 boxes, ORed together. At most 80 across all subscriptions combined
mmsiallUp to 500 MMSI
msg_typesallInteger AIS message ids, 1 to 27. The typical vessel set is [1, 2, 3, 18, 19] — without it, base stations (4), SAR aircraft (9) and aids to navigation (21) would reach a client that renders everything as a ship. None of those three currently reach this stream, but the filter is the contract, not the current feed set
fields"full""full" or "position", see section 6
include_positionlessfalseAlso messages without a coordinate (static data, names)
min_interval_msRejected. See section 10

There is no field to choose a source. Which sources reach a connection is decided entirely by the granted scope, and welcome.key.scope tells you what it was when the connection opened — read it rather than requesting optimistically. A subscribe that still sends a sources field is accepted unchanged; the field is simply unrecognised, see Unknown fields are reported, not rejected below.

The scope can be widened while you are connected — the operator can open the receiver stream to every account at once. That takes effect within a minute, without a close and without a new frame: welcome.key.scope still shows the older, narrower value, and additional messages simply start arriving. Nothing you have to handle, and nothing breaks — but do not treat the value from the welcome as a guarantee that the stream will stay that narrow. If you need the current scope, reconnect and read the new welcome.

A scope that is taken away is the other direction, and that one does close the connection: Close 4002, see the close code table.

Several filters within one subscription are ANDed together: bbox + msg_types means "in this box and of this type". Several boxes among themselves are ORed. Up to 4 subscriptions per connection, and those in turn are ORed — a message that matches two subscriptions still arrives only once.

Two limits apply to boxes: 50 per subscription and 80 across all subscriptions combined (limits.bbox_per_subscription and limits.bbox_per_connection in the welcome). The 80 is the one that binds — plan your boxes against it, not against the 50. Section 9 has the rules for staying inside it while changing a filter.

Unknown fields are reported, not rejected

A subscribe that includes a field this server doesn't recognise is still accepted. The subscribed frame names the field in notes: unknown field 'x' was ignored.

resume in particular gets a longer note — unknown field 'resume' was ignored; there is no resumption, a reconnect starts from now — because the frame id practically invites the field. There is no resumption.

The subscribed frame is not a guarantee

In normal operation the confirmation arrives within milliseconds, and it names the id you sent. It is not promised, and there is no latency you may rely on. Two cases to build for:

  • A rejected subscribe gets an error, not a subscribed. Too soon, an invalid box, too many boxes, a fifth subscription: each answers with an error carrying the id. The previous subscription stays in place.
  • Under back-pressure a confirmation can be lost. Confirmations, errors, notices and pong share one bounded queue. If it is full, the frame is dropped — while the subscription is active. The loss is counted on our side, but nothing reaches you.

So: keep a re-arm timer. If no subscribed and no error for an id arrives within a few seconds, send the subscribe again — it is idempotent, a known id replaces. Do not read the absence of a confirmation as "rejected", and do not read its presence as the only proof the filter is live: the effective block inside it is that proof.

Overlapping subscriptions deliver once

Two subscriptions on the same connection that both match a message still produce exactly one frame — the base rule stated above, under Every subscribe field. The same holds for an overlap between two boxes inside a single subscription.

What that rule doesn't say is which fields the frame is built with. The frame is built once per connection, from the first subscription that matches, in the order you created them. If two overlapping subscriptions ask for different fields, the first one wins for the whole overlap — even though the subscribed frame confirmed each subscription its own value. If you need both profiles reliably, use two connections.

unsubscribe ends one subscription

{"type":"unsubscribe","id":"…"} removes the subscription carrying that id. Only id is read; anything else in the frame is ignored.

Nothing comes back. There is no confirmation frame, and an id that does not exist is not an error either — the frame is then simply a no-op. What does change is your budget: the boxes of the removed subscription stop counting against the 80 per connection, and its slot is free for a new id.

unsubscribe spends nothing from the subscribe allowance (section 9) and is never rate-limited — send as many as you like, whenever you like. It also cannot put you back before the deadline for the first subscribe: a connection that has subscribed once has met that deadline for good. Removing the last subscription leaves the connection open and silent, heartbeats included.

You will rarely need it. A subscribe with a known id replaces that subscription atomically, which is the cheaper way to change a filter.


After a change, frames from the previous filter may still arrive

Replacing or removing a subscription swaps the filter. It does not empty what is already queued for you, so for a short moment after the subscribed frame you can still receive vessels that were selected under the previous filter.

The common case is a map that panned: the user moves the viewport, you replace the dynamic subscription, and a handful of vessels outside the new box still land. That is not a fault on either side.

Emptying the buffer instead would be the worse answer: it would also throw away the frames that are valid under both filters.

Subscription patterns that work

Three things that are hard to derive and expensive to get wrong.

Keep fixed areas and per-user areas under separate ids. A user changing their viewport then re-sends only the small dynamic subscription instead of your whole always-on set. With one combined subscription, every viewport change costs the entire filter.

When merging boxes to fit the limits, merge by smallest added covered area — not by smallest absolute union. The absolute union makes visibly worse choices: it will merge two distant small boxes rather than a small box that already sits inside a large one, and the subscription then pays for the empty gap between them.

Order your subscribes by what your users notice first — positions before names. The allowance covers four frames without waiting (section 9), so a normal initial set costs one round trip; beyond that, order still matters.

5 · Bounding boxes have named edges

{"west":18,"south":59,"east":26,"north":66}

west > east is not an error — it is the statement "across the 180th meridian". Because a swapped pair looks identical, you still get a note in notes.

Limits: latitude −90…90, longitude −180…180. south > north is an error.


6 · Data frames: fields, formats and freshness

Measured against a real contract record:

"full" (default) — 556 bytes.

{"type":"position","id":"1755183845123-0","license":"100","data":{"schema":1,"ingest_ts":"2026-08-13T10:00:00.500Z","rx_ts":"2026-08-13T10:00:00Z","mmsi":230123456,"msg_type":1,"lat":60.161234,"lon":24.934567,"h3_r10":"8a1126d33c37fff","sog":12.3,"cog":45.6,"heading":47,"nav_status":0,"rot":-2.7,"pos_accuracy":true,"shipname":null,"shiptype":null,"imo":null,"callsign":null,"destination":null,"draught":null,"eta":null,"dim_a":null,"dim_b":null,"dim_c":null,"dim_d":null,"length":null,"beam":null,"dedupe_key":"d13eee4b5299487084b80cd5720e6ac9d5d6aa4a"}}

"position" — 195 bytes. Eight fields:

{"type":"position","id":"1755183845123-0","license":"100","data":{"mmsi":230123456,"msg_type":1,"lat":60.161234,"lon":24.934567,"sog":12.3,"cog":45.6,"heading":47,"rx_ts":"2026-08-13T10:00:00Z"}}

If a field is missing, it stands as null rather than being left out entirely: the profile has a fixed field list, and whoever expects eight fields gets eight.

Field formats that will surprise you

eta is MM-DDTHH:MM, or null. No year, no offset — the AIS message carries neither. It is not ISO 8601, and Date.parse returns NaN on it. Parse it by hand against the current year, and expect the value to be in the past when a voyage crosses a year boundary.

The AIS not-available sentinels are mapped to null before they reach you: month 0, day 0 and a malformed value all become null. One case survives: a valid month and day with hour 24 or minute 60 arrives as 12-16T00:00. If midnight matters to you, treat it as "date known, time unknown".

rx_ts and ingest_ts are different clocks at different precision.

FieldFormatMeaning
rx_tsYYYY-MM-DDTHH:mm:ssZWhen the upstream source saw the message. Second precision
ingest_tsYYYY-MM-DDTHH:mm:ss.SSSZWhen we accepted it. Millisecond precision

Use rx_ts for freshness and for merging with another source. It is the only one that describes the event rather than our handling of it. When two records tie on rx_ts, break the tie on ingest_ts. The frame id does not help beyond that: it orders frames only within one source. A key scoped for both collector and receiver sees both mixed on one connection, and each source hands out its id values independently — at low throughput, collector and receiver can even produce the identical id string for two different frames. Do not use id as a cross-source tie-break; we don't have one to offer. Comparing our rx_ts against another provider's receipt time biases the merge toward that provider — put both feeds on their own event timestamps.

Vessel dimensions

Carried in fields:"full" only, as six fields: dim_a to dim_d plus length and beam.

AIS does not transmit a length. It transmits four distances from the position sensor — to the bow, the stern, port and starboard — and length = dim_a + dim_b, beam = dim_c + dim_d. You get both forms, because not every feed knows the four offsets: some deliver the finished dimensions only. A feed that has the offsets fills all six fields; a feed that has only the dimensions fills length and beam and leaves the four offsets null.

Three things to build for:

  • All six are null unless the frame is static data (types 5, 19 and 24 part B). A position report has no dimensions to carry, so a bbox-scoped subscription will almost never see them — you need the static route from section 7.
  • A single 0 is a real value, not a missing one: the sensor sits on that edge. Only the whole group dim_a = dim_b = dim_c = dim_d = 0 means "not available", and then all six fields are null.
  • The ranges differ. dim_a and dim_b go to 511, dim_c and dim_d only to 63 — AIS encodes the pairs with nine and with six bits.

Freshness belongs on rx_ts, not on the arrival rate

A client that hides ships after n seconds without a frame must use rx_ts for that, not the time since the last frame it received. The arrival rate varies: feeds have gaps, and a ship does not report at an even pace. A ship that hasn't sent a frame in a while has therefore not disappeared.

dedupe_key, and what happens when two feeds see the same vessel

dedupe_key is in data, in the full profile only. It is a short checksum over the report itself:

SHA-1( mmsi | rx_ts | lat,lon,sog,cog )

Six values, joined in that order, with the numbers in the contract's canonical form. Every frame carries the key in this one form, whatever the frame came from. Two frames with the same key describe the same report; that is all the field claims.

Now the question that decides your merge logic: do two feeds carrying the same vessel report reach you as one frame or two?

One, when the content is identical. If the same content arrives again for the same MMSI within 60 seconds — same position to four decimal places, same heading, same nav_status — the repeat is not sent. The suppression happens before your subscriptions are consulted at all, so it applies whichever profile or protocol you use. With two overlapping feeds this is the common case, and it is why the same vessel does not arrive twice.

Two, when anything differs. A different rx_ts, a position that moved in the fifth decimal place, a changed heading: that is a new report, it is delivered, and it carries a different dedupe_key. So dedupe_key is not a per-vessel identity and not a cross-feed join key — for "same vessel", use mmsi; for "which report is newer", use rx_ts.

One consequence worth planning for: a vessel that is not moving reports less often than it transmits. A ship at anchor sending the identical position every few seconds reaches you at most once per 60-second window. Do not read a gap in its frames as a lost connection — that is what rx_ts and the heartbeat's feed field are for.

After an upstream outage, the gap stays a gap

An upstream outage of more than a minute is not replayed once the feed recovers: the backlog is discarded rather than delivered late as if it were live. You will see a gap in the stream, with rx_ts on the frames after it jumping forward to match.

This is deliberate. A backlog replayed at full speed is indistinguishable from live traffic at the client, and it would place every vessel at a position it already left. A gap is honest instead — the next frame for each vessel carries where it actually is now.


7 · Getting vessel names: static data

Names, call signs, destinations and ETAs travel in AIS types 5 and 24. Those messages carry no position, and a message without coordinates cannot satisfy a bounding box. They therefore arrive only through a subscription that has no bbox at all:

{"type":"subscribe","id":"static","msg_types":[5,24],"include_positionless":true,"fields":"full"}

Join it to positions by mmsi. Five things to know before you turn it on:

  1. Unscoped, it is global. There is no way to scope static data geographically — but you can scope it by vessel: add mmsi to the same subscription and you get static data only for the vessels you name, up to 500 per subscription. That is the recommended shape once you know which vessels you care about — the subscription above with one field added:
    {"type":"subscribe","id":"static","mmsi":[230123456,230108610],"msg_types":[5,24],"include_positionless":true,"fields":"full"}
    

    Left unscoped, static messages are a substantial fraction of the stream — plan for the same order of magnitude as your position traffic, not a trickle. Size your consumer for that, scope it by mmsi, or leave the subscription off.
  2. Bound your pending store. A client that keeps static records for MMSIs it has never seen a position for will accumulate the world's fleet. Apply static only to vessels already in your cache and accept that a name may take one repeat cycle to appear — that is minutes, not seconds, so do not treat a still-nameless vessel as an error.
  3. The 500 MMSI cap is per subscription, and there is no connection-wide MMSI limit. Four subscriptions can therefore name up to 2,000 vessels between them. If your nameless population is larger than one subscription holds, rotate the slots rather than keeping a fixed list: replace the same subscription id with the next batch. A vessel whose static messages never reach the network would otherwise hold a slot forever.
  4. Type 24 arrives in two parts, and they are separate messages. Part A carries shipname. Part B carries shiptype and callsign. Merge your stored static state field by field. A client that replaces it will clobber the name when part B arrives.
  5. It costs nothing against the 80-box budget — the limit counts boxes, and this subscription has none (section 9).

Which fields are populated for which type

fields:"full" is not a field list. It is the contract payload passed through.

TypeWhat it isPositionshipname / shiptypecallsign / imo / destination / draught / etadimensions
1, 2, 3Class A positionyesnullnullnull
18Class B positionyesnullnullnull
19Class B extended positionyessetnullset
5Class A static and voyagenosetsetset
24 part AClass B staticnoshipname onlynullnull
24 part BClass B staticnoshiptype onlycallsign onlyset

Type 19 is the exception worth knowing: it carries a name and a position in the same frame.


8 · heartbeat — every 20 seconds, even in silence

{"type":"heartbeat","at":"2026-08-14T15:04:25Z","sent":1482,"dropped":0,"lag_ms":340,"feed":"ok","quota_bytes_used":6442450944,"quota_resets_at":"2026-09-01T00:00:00Z"}

An operating requirement, not a convenience. A WS ping also runs, but it is invisible in the browser JS API — which makes the heartbeat the only thing a browser can use to tell "the line is up, nothing is just happening right now" from "the line is dead".

FieldMeaning
sent · droppedCounters for this connection. dropped > 0 means: you are losing data
lag_msDistance to the source. Permanently three digits is normal, four is not
feed"ok" or "stalled"whether anything at all is currently reaching this service. See below

feed answers the one question you cannot answer yourself

A subscription that was accepted and delivers nothing has two possible causes, and from the outside they look identical: nothing is moving inside your filter, or nothing is reaching this service at all. The heartbeat keeps arriving either way, so the connection looks healthy in both cases.

feed separates them, in every heartbeat:

ValueMeaningWhat to do
"ok"Messages are reaching the serviceIf you get nothing, it is your filter. Widen it, or accept the silence
"stalled"Nothing has reached the service for more than 60 secondsNot your filter. Keep the connection open; delivery resumes without any action from you

"stalled" is also what you get from a service that has not yet received its first message — after a restart, for instance. For a client that is receiving nothing, the two are the same event, and in both the cause is not its filter.

This is a statement about the service, not about your subscription. It carries no id. A subscription with no matches while the feed is healthy is the other case, and it has its own frame: notice{empty_subscription} in section 9. Do not build a watchdog on the arrival rate alone — build it on feed, and treat an unknown value as "ok".

dropped counts differently from the threshold for Close 4008.dropped is cumulative since the connection began and spans both kinds of loss (ring buffer and output limit). The advance warning notice{slow_consumer_warning} and the 4008 decision, by contrast, count only ring-buffer losses, and per 60-second window. Comparing dropped against the threshold of 1,000 named there is therefore misleading.


9 · Limits, and what happens when they're exceeded

LimitValueOn exceeding it
Subscriptions per connection4error subscription_too_large and Close 4014, on the first attempt
Boxes per subscription50error subscription_too_large
Boxes per connection (all subscriptions)80error subscription_too_large
MMSI per subscription500error subscription_too_large
Deadline for the first subscribe5 s, as subscribe_deadline_msClose 4003
subscribe frames4 without waiting, then one per 5 serror subscribe_rate_limited; the fifth too-early frame in a row: Close 4006
Connections per account2Close 4005
Too-slow consumer>1,000 losses in 60 snotice{slow_consumer_warning}, then Close 4008
Output ratelimits.output_bytes_per_secondnotice{throttled}, then Close 4009

An oversized subscribe is rejected whole. Nothing is trimmed. Fifty-one boxes, 501 MMSI, a box sum over 80: the frame is refused with subscription_too_large, no part of it is applied, and your previous subscription stays active and unchanged. We would rather refuse than deliver a selection you did not ask for.

Principle: the first violation is a frame, the repeated one is a close. A typo doesn't cost a connection — but never retry a rejected frame verbatim: a second consecutive invalid subscribe ends the connection with Close 4014. Fix the frame or drop it; a blind retry loop is the one client behaviour this rule punishes. One row is the exception: a subscribe with a new id while four subscriptions are already active is answered with subscription_too_large and Close 4014 straight away. Release one with unsubscribe first, or reuse an existing id — that replaces instead of adding.

Working within the subscription limits

A subscribe with an id that already exists replaces that subscription atomically — no unsubscribe needed — and its old boxes therefore do not count twice against the 80-box budget. A subscription with no bbox costs nothing against it at all.

The subscribe allowance is a bucket, not a stopwatch

A fresh connection may send four subscribe frames with no waiting at all.

After that the allowance refills at one frame per five seconds, up to four in hand. The long-run limit is unchanged: a client that keeps changing filters still gets one accepted subscribe per five seconds. What changed is when it may spend them — saved-up allowance can be spent at once.

Only an accepted subscribe spends from the allowance. A subscribe rejected for arriving with an empty bucket costs nothing, so a retry does not push your own deadline further out.

One consequence to plan for. The rate estimate behind effective.estimated_rate_per_s is measured against recent traffic, and it is computed at most once per five seconds per connection — the same interval, for the same reason. A burst of four subscriptions therefore gets that estimate for the first of them; for the rest, estimated_rate_per_s is simply absent from the subscribed frame, and neither notice{empty_subscription} nor notice{feed_stalled} fires. If you want the coverage notice for every subscription, space your subscribes five seconds apart. If you want your map filled quickly, burst them — and read feed in the heartbeat (section 8) for the answer the notices would have given you about the feed itself.

When you fall behind

Your connection has one send buffer, shared by all your subscriptions. When you cannot keep up with it, we drop — and we choose what to drop so that it costs you as little as possible:

  • A superseded position for a vessel that already has one waiting is overwritten. You lose the older position, not the newer one.
  • A static frame never displaces a waiting position. That position would be gone, not merely stale.
  • Beyond that, the oldest frame goes.

Either way, the loss adds to dropped in the next heartbeat. More than 1,000 losses within 60 seconds ends the connection with Close 4008. The window is a tumbling window that starts with the connection, not with a subscription — resubscribing or adding a subscription does not reset it.

Two consequences worth planning for:

  • sent and dropped are cumulative for the life of the connection. A resubscribe resets nothing; a reconnect resets everything, because it is a new session. Emit deltas and guard against the counter appearing to go backwards across a reconnect.
  • A connection can be closed for falling behind before it has ever received a heartbeat reporting a non-zero dropped. Losses are detected far more often than the heartbeat is sent, and once a consumer is behind, 1,000 losses are reached in seconds, not minutes. Do not use dropped as your early-warning signal — use notice{slow_consumer_warning} below, which is sent for exactly this reason and does not wait for the next heartbeat. The two levers are fields:"position" and a narrower filter. The rate that actually reaches you depends on that filter, so measure it once on a throw-away connection rather than assuming a number.

The error frame

An error names what the service refused, and whether the connection survives it:

{"type":"error","code":"invalid_subscription","message":"bbox[0]: south (70) is north of north (60)","id":"ostsee","fatal":false}

fatal says whether a close follows immediately — otherwise the client would have to guess it from the code, and the codes carry both meanings. A rejected subscribe costs the change, not the connection: the previous subscription stays in place.

id is present when the error belongs to a subscription and that subscription had an id. Do not branch on its presence to decide scope: the 80-box and 4-subscription limits are connection-wide but carry the id of the subscribe that tripped them, and a subscribe sent without an id produces subscription errors without one.

notice codes

Advisory, not an error — a notice frame never closes the connection by itself. Two of the four are early warnings for a close that follows only if the underlying condition persists (slow_consumer_warning before Close 4008, throttled before Close 4009, both already in the table above); the other two are never followed by a close at all.

CodeCarries idWhen
empty_subscriptionyesSent once per accepted subscribe, when nothing in the recent traffic fell inside your filter. Widen the bounding box, or drop it once, to see what is flowing
feed_stalledyesSent once per accepted subscribe, when nothing has reached the service recently. The condition is service-wide; the id only says which subscribe surfaced it. Not a coverage limit — do not widen your filter in response
slow_consumer_warningnoHalf the loss threshold is reached, i.e. 500 losses in the window. The connection closes past the full one, at the 1,001st
throttlednoThe output rate limit is dropping frames for this connection

The list grows. Ignore an unknown code rather than failing on it.

{"type":"notice","code":"throttled","message":"output rate limit reached: frames are being dropped for this connection. Narrow your subscription or use 'fields':'position'. The heartbeat field 'dropped' counts every lost frame."}

notice{empty_subscription}the coverage notice — is the one a new integration is most likely to meet. The subscribe was accepted, the feed is healthy, and nothing in the recent traffic fell inside the filter: the most common disappointment on this service is not an error but silence, and this frame is what turns that silence into an answer. It arrives once per accepted subscribe, is not repeated while that subscription stands, and closes nothing — data starts flowing the moment a matching message arrives. What to do with it: widen the bounding box, or drop it once, to see what is currently flowing.

If the box is not yours to choose — a user's map viewport, say — there is nothing to widen, and that is fine: the notice is then a statement about the area, not a task. Show the empty map, and use feed in the heartbeat (section 8) to tell your users the difference between "nothing here" and "nothing anywhere". The service withholds the notice rather than guessing when it cannot yet judge — shortly after a restart, and while the upstream feed is stalled, which is what the next notice is for.

The two can never both apply to one subscribe. They come from the same measurement of recent traffic, and it reports one of three states: usable and your filter matched nothing → empty_subscription; too old to mean anything → feed_stalled; not yet usable → neither, and you get silence. So empty_subscription is structurally unreachable while nothing is flowing, and a recovery cannot produce both.

notice{feed_stalled} is the counterpart to the coverage notice, not a replacement for it: the upstream feed itself has delivered nothing for more than 60 seconds — that is not a coverage limit, and the client should therefore not narrow or widen its filter because of it. It fires only when a subscription is created while the stall is already under way, so it does not cover the duration of a stall — only that one moment.

For a stall that begins after you subscribed, read feed in the heartbeat (section 8). That is the signal built for exactly this case, it arrives every 20 seconds, and it needs no new subscription to reach you.

The upstream feed has not delivered recently. Not a coverage limit — do not widen your filter in response.

{"type":"notice","code":"feed_stalled","id":"ostsee","message":"No messages have reached this service recently. This is not a coverage limit and not a problem with your filter: the upstream feed is not currently delivering. Keep the connection open; delivery resumes without any action from you."}

notice{slow_consumer_warning} is the advance warning before Close 4008: it arrives at half the loss threshold (500 of 1,000 losses in the 60-second window), at most once per window. dropped alone doesn't work as an early warning: in exactly the window where the heartbeat would be reporting the losses, it no longer arrives in time. This notice goes out independently of the heartbeat cadence, as soon as the halfway point is reached, leaving you the larger half of the window to react. The number in message is the count at the moment it fired, so depending on the loss rate it can already be well above 500 — as in the following example with 650.

{"type":"notice","code":"slow_consumer_warning","message":"dropping frames: 650 lost in this window; the connection closes at 1000 in this window (a separate, per-window count from the heartbeat's cumulative 'dropped'). Narrow your subscription or use 'fields':'position'."}

The wording in that frame names the threshold, not the exact trigger: 1,000 losses in the window still leave the connection standing, the 1,001st ends it.

All close codes

CodeNameMeaning
4000protocol_errorFrame was not a valid JSON object, or the type is unknown
4001unauthorizedReserved — not sent by this service today. A missing or rejected key is answered with HTTP 401 before the upgrade completes, never with this close code
4002forbidden_scopeThe granted scope was narrowed while the connection was open and at least one subscription was active. Only a withdrawal closes: a scope that is widened mid-connection is applied silently, without a close and without a new frame. Reconnect to see the new scope in the welcome
4003subscribe_timeoutNo subscribe within the deadline from the welcome frame
4004invalid_subscriptionSubscription invalid repeatedly
4005too_many_connectionsToo many concurrent connections — this account's own limit or the service's overall limit; the close reason names which
4006subscribe_rate_limitedFive subscribe frames in a row that each arrived with an empty allowance — see The subscribe allowance is a bucket
4007idle_timeoutNo WebSocket-level pong within 45 seconds — the transport pong, not the JSON pong frame. Browsers answer it automatically
4008slow_consumerThe client reads too slowly; the reason names the number of dropped messages
4009rate_limitedThe output byte rate limit was exceeded for 60 seconds
4011key_revokedKey revoked — within 60 seconds, even on an open connection
4012server_restartRedeploy. Reason: redeploy; reconnect after 5-30s jitter
4013upstream_unavailableReserved — not sent by this service today. The key store being unreachable during authentication is answered with HTTP 503 before the upgrade completes, never with this close code
4014subscription_too_largeToo many bounding boxes or MMSI for one subscription, too many bounding boxes across the connection, or too many subscriptions on the connection

Every fatal close is preceded by an error frame with the same code — some client libraries swallow the close reason, and the user would then see only a number.

4001 and 4013 exist as reserved identifiers but are not sent by this service today: a key that is missing, unknown, or cannot be verified, and a key store that is temporarily unreachable, are both decided before the WebSocket upgrade — as an HTTP status with a JSON body, not as a close code. The same is true for one of the rejections below: a key in the query string. It is answered as HTTP, and the connection never becomes a WebSocket at all:

HTTP statusCodeWhen
400key_in_queryThe API key was placed in the query string instead of a header
401unauthorizedThe key is missing, unknown, or rejected
401stream_capability_missingThe key is valid but was not sold for the stream. It grants access to the HTTPS API only; ask for the stream capability to be added to it
503too_many_connectionsThe service is at its overall connection limit
503upstream_unavailableThe key store is temporarily unreachable

The remaining one — the service's overall connection limit — is the exception in this table: it can arrive either way. The limit is checked twice, once before the upgrade and once more when the finished connection is registered, because another client can take the last free slot between those two moments. The usual answer is the 503 above; a connection that loses that race is accepted and then ended with Close 4005, whose reason names the service limit rather than the account limit. Handle both.

4012 carries the reason redeploy; reconnect after 5-30s jitter. The jitter is meant seriously: every client of this service reconnecting in the same second is a self-inflicted load attack.

4011 arrives within 60 seconds of a revocation — even on an open connection. A withdrawn key does not stay valid until the next time a connection is established.


10 · min_interval_ms is rejected

A subscribe carrying this field (other than 0) gets an error, not a silent no-op. This is a deliberate departure from the original design, and the reason lies in the architecture:

A frame is prepared once and shared by everyone it goes to, and by the time it reaches your connection it is no longer attributable to one of your subscriptions. Throttling the whole connection instead would be easy — but as soon as a connection carries two subscriptions and only one of them asked for it, we would be discarding messages belonging to the other one. That is exactly silent throttling, the one thing this protocol is built against.

That leaves two honest answers: honour the promise, or don't make it. What the field was meant to achieve is usually better served by a narrower selection — mmsi, a smaller bbox, fields: "position".


11 · What this service carries, and what it does not

Coverage is broad but uneven. Global coverage is sampled — roughly one position per vessel per minute. Nordic waters are continuous.

This is stated this plainly because no protocol changes it. Whoever learns the limit beforehand stays for what is covered; whoever only discovers it after integrating leaves.

Deliberate non-features

Each of these has been asked for and answered. They are listed together so you do not design against a capability that will not arrive.

  • No resumption by frame id. There is no history buffer and a reconnect starts from now. Reconcile with rx_ts instead, and keep your vessel cache across the reconnect.
  • No per-vessel state on the server. This service holds no last-known state for a vessel, which is why two things do not exist and will not: inline static enrichment (names attached to position frames) and bbox on a static subscription (filtering positionless messages by a remembered position). Scope static data by mmsi instead — see section 7.
  • No permessage-deflate. A deflate stream is stateful per connection. fields:"position" is the alternative and is 2.85× more effective than compression would be.
  • No min_interval_ms. See section 10 for why a per-subscription promise cannot be kept here.
  • No per-source capability or coverage table. welcome.sources[] names the sources that reach your connection; which message types and which regions each one carries is not published.
  • No message types 4, 9 or 21. Base stations, SAR aircraft and aids to navigation do not currently reach this stream. Filter for them anyway if you care: the filter is the contract, not the current feed set.

12 · A complete client

const ws = new WebSocket("wss://stream.pelyr.com/v1/stream",
                         ["pelyr.v1", "pelyr-key." + KEY]);

ws.onmessage = (ev) => {
  const frame = JSON.parse(ev.data);
  switch (frame.type) {
    case "welcome":
      // Attribution is mandatory, and belongs where the user actually sees it.
      showAttribution(frame.sources.map(s => s.attribution));
      ws.send(JSON.stringify({
        type: "subscribe",
        id: "ostsee",
        bbox: [{ west: 18, south: 59, east: 26, north: 66 }],
        fields: "position",
      }));
      break;

    case "subscribed":
      // effective, not what was requested — this is where a gap shows up.
      console.log("active:", frame.effective);
      break;

    case "position":
      drawShip(frame.data);
      break;

    case "heartbeat":
      if (frame.dropped > 0) console.warn("data loss:", frame.dropped);
      break;

    case "error":
      console.error(frame.code, frame.message);
      break;
  }
};

// 4012 means redeploy — reconnect with jitter, not immediately.
ws.onclose = (ev) => {
  const waitTime = ev.code === 4012 ? 5000 + Math.random() * 25000 : 1000;
  setTimeout(reconnect, waitTime);
};

Appendix · Frame types at a glance

TypeDirectionWhen
welcome← ServerOnce, immediately after the upgrade
subscribe→ ClientWithin 5 s, any time after that
unsubscribe→ ClientEnds a subscription by its id; unanswered, see section 4
subscribed← ServerFor every accepted subscribe; a rejected one gets an error, and under back-pressure the frame can be lost — see section 4
position← ServerContinuously
heartbeat← ServerEvery 20 s
error← ServerOn violations, fatal says whether a close follows
notice← ServerAdvisories without an error
ping / pong→ / ←Optional, for proxies that hide WS pings; max. 1 pong per second, see section 2

All frames are text frames, UTF-8, one JSON object per frame, and type always comes first — a client can read the type without parsing the whole frame.