Pelyr OPEN-AIS Data API
HTTP API for AIS vessel data: live positions, vessel details, and historical tracks.
Base URL: https://api.pelyr.com
If you need a continuous feed rather than polling, use the WebSocket stream at
wss://stream.pelyr.com instead. Both use the same API key.
Quick start
curl -H "Authorization: Bearer $PELYR_KEY" \
"https://api.pelyr.com/v1/vessels?bbox=5,51,7,52&max=100"
{
"generated_at": "2026-08-30T14:22:10Z",
"count": 1,
"truncated": false,
"vessels": [
{
"mmsi": 244660123,
"position": {
"lat": 51.18597, "lon": 5.964237, "sog": 6.8, "cog": 90.8,
"heading": 273, "nav_status": 0, "rot": null, "pos_accuracy": false,
"msg_type": 1, "ts": "2026-08-30T14:22:11Z", "license": "100"
},
"static": {
"name": "EXAMPLE TRADER", "type": 70, "imo": 9543756,
"callsign": "V7WW7", "dest": "ROTTERDAM", "draught": 6.8,
"eta": "11-06T03:00", "length": 257, "beam": 43,
"dim_a": 214, "dim_b": 43, "dim_c": 24, "dim_d": 19,
"ts": "2026-08-30T13:47:02Z", "license": "1"
}
}
]
}
Authentication
Send your key as a bearer token:
Authorization: Bearer pk_<43 characters>_<checksum>
The key must have the https capability. Keys are created in the portal; the
same key works for the WebSocket stream if it also carries stream.
Do not put the key in the query string. Requests without an
Authorization header that carry a query parameter named key, token,
api_key, apikey, access_token, access-token, api-key, auth,
bearer, secret, or sig are rejected with 400 key_in_query.
| Situation | Status | Code |
|---|---|---|
No or malformed Authorization header | 401 | unauthorized |
| Unknown, malformed, or revoked key | 401 | unauthorized |
| Account suspended | 403 | stream_account_suspended |
Key lacks the https capability | 403 | forbidden |
Revocation takes effect immediately.
There are no CORS headers; the API is not callable from browser page code.
Rate limits
Sliding 60-second window, counted per key. Three independent budgets:
| Endpoints | Limit |
|---|---|
/v1/vessels, /v1/vessels/{mmsi} | 12 requests / minute |
/v1/vessels/{mmsi}/track | 12 requests / minute |
/v1/sources | 1 request / minute |
Exhausting one budget does not affect the others. Over the limit you get
429 rate_limited with a Retry-After header in whole seconds.
Conditional requests
Every successful response carries a strong ETag. Send it back to avoid
transferring an unchanged body:
curl -H "Authorization: Bearer $PELYR_KEY" \
-H 'If-None-Match: "8f43a1..."' \
"https://api.pelyr.com/v1/vessels?bbox=5,51,7,52"
An unchanged result returns 304 with no body. A 304 counts against your
rate limit.
Every response carries Cache-Control: private, no-cache.
GET /v1/sources
The attribution directory. Every position and static record carries a
license id; this endpoint resolves those ids to licence, source, and the
attribution line you must display.
No parameters.
curl -H "Authorization: Bearer $PELYR_KEY" https://api.pelyr.com/v1/sources
{
"generated_at": "2026-08-30T14:20:00Z",
"sources": [
{"id": "0", "license": "NOASSERTION", "source": "", "attribution": ""},
{"id": "1", "license": "Pelyr", "source": "Pelyr",
"attribution": "Data provided by Pelyr"},
{"id": "100", "license": "CC-BY-4.0", "source": "Digitraffic Finnland",
"attribution": "Traffic data from Fintraffic / digitraffic.fi, licence CC BY 4.0"}
]
}
Ids 0 and 1 are always present. id is always a string, even though it
looks like a number. source and attribution may be empty strings, but are
never null and never absent.
The directory changes rarely. Fetch it once and resolve license ids locally;
the endpoint allows one request per minute.
GET /v1/vessels
All vessels currently inside a bounding box.
| Parameter | Required | Default | Notes |
|---|---|---|---|
bbox | yes | — | west,south,east,north in degrees. west < east, south < north. Boxes crossing the antimeridian are not supported. |
max | no | 2000 | 1 to 15000. |
curl -H "Authorization: Bearer $PELYR_KEY" \
"https://api.pelyr.com/v1/vessels?bbox=5,51,7,52&max=500"
Response fields: generated_at, count, truncated, vessels[]. count is
the number of vessels actually delivered — it always equals the length of
vessels.
Each vessel has mmsi, position, and optionally static. static is
omitted entirely, not set to null, while no static message has arrived for
that vessel — expect it to be missing for up to six minutes after a vessel
first appears.
position
| Field | Unit | Nullable |
|---|---|---|
lat, lon | degrees, WGS 84 | no |
sog | knots | yes |
cog | degrees | yes |
heading | degrees true | yes |
nav_status | AIS navigational status, 0–15 | yes |
rot | degrees per minute | yes |
pos_accuracy | boolean | yes |
msg_type | AIS message type, 1–27 | yes |
ts | reception time, ISO 8601 UTC | no |
license | licence id, see /v1/sources | no |
static
name, type, imo, callsign, dest, draught, eta, length, beam,
dim_a to dim_d, ts, license. All values may be null. length and
beam are metres, derived from the four antenna offsets.
position.license and static.license can differ — the two records may come
from different sources.
When truncated is true
More vessels were in the box than were delivered. The ones you get are spread across the box rather than clustered in one corner.
To get full coverage, split the box into smaller ones. Raising max returns
more vessels from the same area.
Vessels above 85.05°N or below 85.05°S never appear here. Fetch them individually by MMSI. This will be fixed soon.
GET /v1/vessels/{mmsi}
One vessel, same shape as an entry in vessels[] — no envelope.
curl -H "Authorization: Bearer $PELYR_KEY" \
https://api.pelyr.com/v1/vessels/244660123
mmsi must be between 100000000 and 999999999; anything else is 422.
404 means "nothing heard from this vessel for over an hour", not "no such
vessel". There is no ship register behind this API. A valid MMSI can return
404 now and 200 an hour later.
This is also the only way to reach a vessel outside the polar limits of the box query.
GET /v1/vessels/{mmsi}/track
Positions of one vessel over time, bucketed to a fixed resolution.
| Parameter | Required | Default | Notes |
|---|---|---|---|
from | yes | — | ISO 8601 UTC, must end in Z. |
to | no | now | Same format. |
resolution | yes | — | 1min, 5min, 15min, 1h, 6h, 1d. |
curl -H "Authorization: Bearer $PELYR_KEY" \
"https://api.pelyr.com/v1/vessels/244660123/track?\
from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z&resolution=5min"
{
"mmsi": 244660123,
"from": "2026-09-01T00:00:00Z",
"to": "2026-09-02T00:00:00Z",
"resolution": "5min",
"count": 2,
"points": [
{
"lat": 51.18597, "lon": 5.964237, "sog": 6.8, "cog": 90.8,
"heading": 273, "nav_status": 0, "rot": null, "pos_accuracy": false,
"msg_type": 1, "ts": "2026-09-01T00:00:00Z", "license": "100"
},
{
"lat": 51.18612, "lon": 5.972905, "sog": 7.1, "cog": 88.4,
"heading": 271, "nav_status": 0, "rot": null, "pos_accuracy": false,
"msg_type": 1, "ts": "2026-09-01T00:05:00Z", "license": "100"
}
]
}
The window is half-open: a point at exactly from is included, a point at
exactly to is not. to is always present in the response, even if you
omitted it.
Point fields are the same as position above, with two differences:
tsis the start of the bucket on the clock, not the reception time. At1hit is the full hour, at1dmidnight UTC.msg_typeis nevernull.
At most one point per bucket. Empty buckets produce no point at all, so
points is not evenly spaced — count is the number of points, not the
number of buckets in the range.
A vessel with no reports in the range returns 200 with an empty points
list. Never 404.
How far back you can query
Each resolution has its own maximum range and its own history depth:
| Resolution | Max range per request | Directly queryable back to |
|---|---|---|
1min | 7 days | 48 hours |
5min | 30 days | 1 month |
15min | 90 days | 2 months |
1h | 365 days | 5 years |
6h | 5 years | 10 years |
1d | unlimited | unlimited |
Exceeding the range gives 400 track_span_too_large.
Going further back than the third column requires the archive, which is
unlocked per key. Without it you get 403 archive_not_enabled, and the
response body contains a ready-to-send email:
{
"error": {
"code": "archive_not_enabled",
"message": "This time range is beyond the directly queryable retention. Archive queries are enabled per key -- the finished request is in request_template."
},
"contact": "hello@pelyr.com",
"request_template": {
"to": "hello@pelyr.com",
"subject": "Archive access for key 3f1c9a02-...",
"body": "..."
}
}
The template contains your key id — never the key itself — plus the MMSI, range, and resolution you asked for.
Only from decides. A range that starts before the cutoff and ends after it
is an archive query in full; it is not split across sources.
400 range_before_retention means from predates the recorded history
entirely. Unlocking does not help; there is no data for that range.
Errors
Every error has the same envelope:
{"error": {"code": "invalid_resolution", "message": "resolution must be one of 1min, 5min, 15min, 1h, 6h, 1d."}}
Branch on code, never on message. Messages are English text and change
without notice.
| Code | Status | When |
|---|---|---|
key_in_query | 400 | Key passed as a query parameter |
invalid_bbox | 400 | bbox parses but is geographically invalid |
invalid_resolution | 400 | Unknown resolution |
invalid_time_range | 400 | from/to malformed, missing Z, or from >= to |
track_span_too_large | 400 | Range longer than the resolution allows |
range_before_retention | 400 | from predates the recorded history |
unauthorized | 401 | Missing, malformed, unknown, or revoked key |
forbidden | 403 | Key lacks the https capability |
stream_account_suspended | 403 | Account is suspended |
archive_not_enabled | 403 | Archive needed, key not unlocked |
not_found | 404 | Vessel silent for over an hour |
bad_request | 422 | Parameter fails schema validation |
rate_limited | 429 | Over the rate limit; see Retry-After |
internal_error | 500 | Unhandled server error |
Notes:
- Code and status are not one-to-one. Three different codes return 403. Do not infer one from the other.
- 400 versus 422. A
bboxthat does not match the expected shape at all is422 bad_request; one that parses but is geographically impossible is400 invalid_bbox. Likewise a missingresolutionis422, an unknown one is400. - Tolerate unknown fields and unknown codes. New top-level fields may
appear alongside
error, ascontactandrequest_templatealready do. Retry-Afterappears only on429.
Reference
- Attribution obligations:
GET /v1/sourcesand the licence terms it points to.