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.

SituationStatusCode
No or malformed Authorization header401unauthorized
Unknown, malformed, or revoked key401unauthorized
Account suspended403stream_account_suspended
Key lacks the https capability403forbidden

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:

EndpointsLimit
/v1/vessels, /v1/vessels/{mmsi}12 requests / minute
/v1/vessels/{mmsi}/track12 requests / minute
/v1/sources1 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.

ParameterRequiredDefaultNotes
bboxyeswest,south,east,north in degrees. west < east, south < north. Boxes crossing the antimeridian are not supported.
maxno20001 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

FieldUnitNullable
lat, londegrees, WGS 84no
sogknotsyes
cogdegreesyes
headingdegrees trueyes
nav_statusAIS navigational status, 0–15yes
rotdegrees per minuteyes
pos_accuracybooleanyes
msg_typeAIS message type, 1–27yes
tsreception time, ISO 8601 UTCno
licenselicence id, see /v1/sourcesno

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.

ParameterRequiredDefaultNotes
fromyesISO 8601 UTC, must end in Z.
tononowSame format.
resolutionyes1min, 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:

  • ts is the start of the bucket on the clock, not the reception time. At 1h it is the full hour, at 1d midnight UTC.
  • msg_type is never null.

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:

ResolutionMax range per requestDirectly queryable back to
1min7 days48 hours
5min30 days1 month
15min90 days2 months
1h365 days5 years
6h5 years10 years
1dunlimitedunlimited

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.

CodeStatusWhen
key_in_query400Key passed as a query parameter
invalid_bbox400bbox parses but is geographically invalid
invalid_resolution400Unknown resolution
invalid_time_range400from/to malformed, missing Z, or from >= to
track_span_too_large400Range longer than the resolution allows
range_before_retention400from predates the recorded history
unauthorized401Missing, malformed, unknown, or revoked key
forbidden403Key lacks the https capability
stream_account_suspended403Account is suspended
archive_not_enabled403Archive needed, key not unlocked
not_found404Vessel silent for over an hour
bad_request422Parameter fails schema validation
rate_limited429Over the rate limit; see Retry-After
internal_error500Unhandled 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 bbox that does not match the expected shape at all is 422 bad_request; one that parses but is geographically impossible is 400 invalid_bbox. Likewise a missing resolution is 422, an unknown one is 400.
  • Tolerate unknown fields and unknown codes. New top-level fields may appear alongside error, as contact and request_template already do.
  • Retry-After appears only on 429.

Reference

  • Attribution obligations: GET /v1/sources and the licence terms it points to.