Pelyr OPEN-AIS Data API

HTTP API for AIS vessel data: live positions, vessel details, a vessel search by MMSI, IMO or name, historical tracks, and vessels detected in satellite images and STS finds.

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. That includes the MCP server: its next tool call that reads data through the API is refused.

API keys

One set of keys serves the stream, the HTTPS API and the MCP server. Keys are created in the portal, and two limits apply to creating them:

LimitValueWhen exceeded
Active keys per accountby plan (see pricing); if your GitHub account was less than 30 days old when you signed up, 1 key on Free, Legacy Free, Community access and Enterprise (Fleet, Horizon and Global always allow their full number); revoked keys do not count400 key_limit_reached
New keysat most 5 key creation requests per hour per account, failed ones included429 rate_limited

The keys that AI assistants such as Claude.ai and ChatGPT receive when you connect them count toward neither limit.

There are no CORS headers; the API is not callable from browser page code.

Rate limits

Sliding 60-second window, counted per key. Independent budgets:

EndpointsLimit
/v1/vessels, /v1/vessels/{mmsi}, /v1/vessels/at, /v1/vessels/search, /v1/detection/areas, /v1/detection/areas/{area_id}/overflights, /v1/detection/overflights/{overflight_id}, /v1/detection/findsthe per-minute limit of your plan; Free plan: 2 per minute per API key
/v1/vessels/{mmsi}/trackthe per-minute limit of your plan; Free plan: 2 per minute per API key
/v1/sources1 request / minute
reads of /v1/alert-zones and /v1/alert-channels (GET)the per-minute limit of your plan
changes of /v1/alert-zones and /v1/alert-channels (POST, PATCH, DELETE)the per-minute limit of your plan

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 except GET /v1/vessels/search and the four GET /v1/detection/ routes carries a strong ETag; those carry none and never answer 304. 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 to display: always for a third-party source, and for the Pelyr entry where section 3.1 of the Pelyr Data Licence requires it — for data that may be used for non-commercial purposes only.

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
bboxyes—west,south,east,north in degrees. west < east, south < north. Boxes crossing the antimeridian are not supported.
maxno20001 to 15000. The plan caps it: Free 1000, Fleet 5000, Horizon 10000, Global 15000; a larger value is cut to the cap.
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
plausibilityunknown, high, medium or low — see belowno
flagswhy plausibility is not high, see below; empty for high and unknownno

plausibility and flags

Every position carries a plausibility: how plausible it is that this reported position is the actual ship's position. flags says why a position is not high.

plausibilityMeaning
highnothing speaks against this position
mediumsomething does not add up; see flags
lowthis position is unlikely to be real; see flags
unknownnot assessed yet
FlagMeaning
implausible_speedthe ship was moving impossibly fast
unreconciled_trackthe ship's track is not plausible
invalid_mmsithe MMSI is not a valid identifier
speed_mismatchthe ship moves much faster than it reports
course_mismatchthe ship moves in a different direction than it reports
moving_while_mooredthe ship reports being anchored or moored, but is moving

flags is empty for high and unknown.

This is not a guarantee that a position is genuine.

Senders that are not ships

/v1/vessels can also list senders that are not ships: aids to navigation, search-and-rescue aircraft, distress beacons, handheld radios and craft associated with a parent vessel. They carry a position like any vessel. You recognise them by the structure of the MMSI, where MID is a three-digit maritime identification digits code in the range 201–775:

SenderMMSI structure
ShipMID + 6 digits
Aid to navigation99 + MID + 4 digits
SAR aircraft111 + MID + 3 digits
SART, MOB, EPIRB970, 972 or 974 + 6 digits
Handheld VHF with GNSS8 + MID + 5 digits
Craft associated with a parent ship98 + MID + 4 digits

Anything else — including a valid prefix with a MID outside 201–775 — is not a recognised identifier and carries the invalid_mmsi flag.

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, up to the plan's cap.

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". This endpoint reads only the live state. A valid MMSI can return 404 now and 200 an hour later. Vessels that are silent or unknown here are found with the search, /v1/vessels/search (below).

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
fromyes—ISO 8601 UTC, must end in Z.
tononowSame format.
resolutionyes—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 three 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.
  • plausibility and flags are absent — tracks carry accepted positions only, without the live consistency check.

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 days30 days
15min90 days60 days
1h365 days1825 days (5 years)
6h1825 days (5 years)3650 days (10 years)
1dunlimitedunlimited

Exceeding the range gives 400 track_span_too_large.

Your plan narrows this further: it sets the resolutions you may request and how many days back from may reach, counted from the start of that UTC day (see pricing). A request outside your plan gets 403 history_outside_plan; the message names the plan limit.

Going further back than the third column requires the archive, which is not queryable yet. Archive access is not part of any plan. Without it you get 403 archive_not_enabled; a key already enabled for the archive gets 503 track_unavailable. The 403 body contains a ready-to-send email, which for now registers your interest:

{
  "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.

GET /v1/vessels/at

Every vessel in a box at one instant, each with the age of its position — a snapshot of the past, from the recorded history rather than the live state.

The time slice is part of the paid plans from Fleet up (see pricing). On the Free plan the request gets 403 time_slice_not_in_plan. The time slice of the dashboard map is not affected.

ParameterRequiredDefaultNotes
atyes—ISO 8601 UTC, must end in Z. Not in the future; within the current UTC day or the two days before it (at least 47 hours back).
bboxyes—west,south,east,north in degrees, as for GET /v1/vessels.
curl -H "Authorization: Bearer $PELYR_KEY" \
  "https://api.pelyr.com/v1/vessels/at?at=2026-09-01T14:30:00Z&bbox=5,51,7,52"
{
  "at": "2026-09-01T14:30:00Z",
  "lookback_seconds": 959,
  "truncated": false,
  "count": 1,
  "vessels": [
    {
      "mmsi": 244660123,
      "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-01T14:22:11Z", "age_seconds": 469,
      "license": "100"
    }
  ]
}

The lookup window is the lookback_seconds (16 minutes) before at. A vessel qualifies if it reported from inside the box at least once in that window; it then appears with its most recent position in the window — even if that position already lies outside the box because the vessel has just left it. Filter the response yourself if you only want positions inside the box. age_seconds is the position's distance to at. A vessel whose last report is older than the window is simply absent — that does not make it a "dark" vessel.

Nothing is interpolated or dead-reckoned: every coordinate is exactly what the vessel broadcast. The fields are those of a track point, plus mmsi and age_seconds; ts is the reception time to the second, not a bucket start.

An empty box returns 200 with an empty vessels list. Never 404.

truncated: true means the window held more vessels than the cap of 5000; vessels is then empty, not cut short — a partial list would make every missing vessel look as if it had not transmitted. Shrink the box.

Limits: at in the future is 400 invalid_time_range; at before the retained minute history — the current UTC day and the two days before it, so at least 47 hours back — is 503 track_unavailable. This endpoint reads the minute history, not the archive; for older instants use the track of a single vessel. The call shares the snapshot budget with GET /v1/vessels.

GET /v1/vessels/search

Find vessels by MMSI, IMO number or name — including vessels that are not transmitting right now. The answer is a ranked list of candidates, not a single vessel.

ParameterRequiredDefaultNotes
mmsione of the three—100000000 to 999999999.
imoone of the three—Seven digits with a valid check digit.
nameone of the three—A current or former vessel name.
maxno101 to 25.

Send exactly one of mmsi, imo and name. None of them, or more than one, is 400 bad_request; so is an IMO number with a wrong check digit and a name without a single letter or digit. A value of the wrong type or outside its range is 422. A search that finds nothing returns an empty results list, not 404.

curl -H "Authorization: Bearer $PELYR_KEY" \
  "https://api.pelyr.com/v1/vessels/search?name=EXAMPEL%20TRADER"
{
  "generated_at": "2026-10-02T09:15:04Z",
  "count": 2,
  "truncated": false,
  "results": [
    {
      "mmsi": 244660123,
      "flag_country": "NL",
      "match": {"kind": "similar", "field": "name",
                "matched_name": "EXAMPLE TRADER", "score": 0.67},
      "registry": {
        "name": "EXAMPLE TRADER", "imo": 9543756, "callsign": "V7WW7",
        "type": 70, "length": 257, "beam": 43,
        "dim_a": 214, "dim_b": 43, "dim_c": 24, "dim_d": 19,
        "first_seen": "2026-09-27T08:10:00Z", "last_seen": "2026-10-02T06:41:00Z",
        "ts": "2026-10-02T06:41:12Z", "license": "1"
      },
      "static": {
        "name": "EXAMPLE TRADER", "type": 70, "imo": 9543756,
        "callsign": "V7WW7", "dest": "ROTTERDAM", "draught": 6.8,
        "eta": "10-03T03:00", "length": 257, "beam": 43,
        "dim_a": 214, "dim_b": 43, "dim_c": 24, "dim_d": 19,
        "ts": "2026-10-02T08:47:02Z", "license": "1"
      },
      "position": {
        "live": true,
        "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-10-02T09:14:51Z", "license": "100",
        "plausibility": "high", "flags": []
      }
    },
    {
      "mmsi": 244660456,
      "flag_country": "NL",
      "match": {"kind": "similar", "field": "former_name",
                "matched_name": "EXAMPLE TRADERS", "score": 0.55},
      "registry": {
        "name": "NORDIC LINK", "imo": null, "callsign": "PD4411",
        "type": 79, "length": 110, "beam": 14,
        "dim_a": 90, "dim_b": 20, "dim_c": 7, "dim_d": 7,
        "first_seen": "2026-09-27T09:02:00Z", "last_seen": "2026-09-30T22:10:00Z",
        "ts": "2026-09-30T22:10:44Z", "license": "100"
      },
      "position": {
        "live": false,
        "lat": 53.54112, "lon": 8.580334, "sog": 0.0, "cog": 211.3,
        "heading": 30, "nav_status": 5, "rot": null, "pos_accuracy": true,
        "msg_type": 1, "ts": "2026-09-30T22:14:08Z", "license": "100"
      }
    }
  ]
}

count is the number of results delivered. truncated: true means more candidates matched than were delivered; make the name more specific or raise max.

match

FieldMeaning
kindexact, prefix (the name starts with your query) or similar (spelled alike)
fieldwhat matched: mmsi, imo, name (the current name) or former_name
matched_namethe name that matched, as last seen; for mmsi and imo the current name, or null if none is known
score0 to 1; 1 for exact

A similar hit is a candidate, not an identification. Names repeat: several vessels can carry the same or a near-identical name at the same time. Check MMSI, IMO number and dimensions before you rely on a hit.

Results are ordered exact before prefix before similar, a current name before a former one, then by score, then by when the name was last seen. Each vessel appears once, with its best match.

How names are compared: case and accents are ignored, punctuation counts as a space, a leading MV, MT or MS is dropped, and only the first 20 characters count. Letters without a plain Latin equivalent, such as Ø, Æ and ß, are dropped rather than transcribed — search for BJORN, not BJØRN. A name shorter than three characters is matched exactly only.

For mmsi and imo every result has kind: exact. An IMO number can return more than one vessel: a vessel that changed flag gets a new MMSI and keeps its IMO number.

registry, static and position

Each result has mmsi, flag_country and match, and up to three groups. A group is omitted entirely, not set to null, when there is nothing for it. Inside a group every field is present and may be null.

  • registry — the vessel register: name, imo, callsign, type, length, beam, dim_a to dim_d, first_seen, last_seen, ts, license. It is durable but has no voyage data. The register only knows vessels that sent static data since it was started; there is no history from before that. first_seen is the start of our observation, not the date the vessel was built or named. ts and license describe the newest static message. Single fields can stem from an older message or another source, so the licence of this group applies to the record as a whole, not to each field.
  • static — the same group as on GET /v1/vessels, including dest, eta and draught. It is present only while the vessel is transmitting.
  • position — the fields of position above, plus live.
    • live: true is the current position, with plausibility and flags.
    • live: false is the last known position from the past 30 days. sog, cog, heading, nav_status and rot are the values the vessel reported at ts, not its current state. There is no plausibility and no flags.
    • No position in the past 30 days: position is omitted.

registry.license and the license of a live: false position are the licence recorded when the message was stored. Such an id can be one that GET /v1/sources no longer lists.

flag_country

The ISO 3166-1 alpha-2 code of the country the MMSI is allocated to, from its maritime identification digits; null when they map to no single country. It is derived from the number, not reported by the vessel, and is not covered by any licence. It says where the transponder's MMSI was assigned, not where the vessel is registered today.

The call shares the snapshot budget with GET /v1/vessels. It carries no ETag.

Vessel detection

Pelyr searches satellite images of chosen areas of sea for vessels and compares what it finds with the AIS positions around the time of the image. Four reads cover the results: the areas, the overflights of one area, one overflight with everything found on it, and the STS finds across all areas. An STS find is a pair of vessels lying alongside each other, as in a ship-to-ship transfer.

Vessel detection is part of the paid plans from Fleet up, and of Legacy Free and Community access (see pricing). On the Free plan every detection request gets 403 detection_not_in_plan, which does not count against the rate limit.

All four need the https capability, carry no ETag, and count against the same budget as GET /v1/vessels, GET /v1/vessels/{mmsi}, GET /v1/vessels/at and GET /v1/vessels/search (see Rate limits). The API carries no images: the answers describe what was found in the image, not the image.

What holds for every answer in this section:

  • The results are automated readings of satellite images, not verified findings.
  • A pair carries no score. Its seven reasons say what speaks for an STS transfer and what against it.
  • A pair is preliminary until its one re-scoring. It is published as preliminary and re-scored once, when AIS from after the acquisition has arrived; after that it is final, and its reasons never change again; a find can still be withdrawn, as a false alarm or when its area is taken out of service. The reasons may differ between the two.
  • False alarms are left out. Detections and pairs that reviewers marked as false alarms are left out, and no review status is ever included.
  • AIS facts can come from sources whose positions this API delivers nowhere else. A vessel listed here can be missing from GET /v1/vessels. Each fact carries the license of its reported AIS position, a string as everywhere in this API: resolve it with GET /v1/sources and show the attribution line wherever you show the position. The vessel name, ship type and size come from the vessel registry and carry no licence of their own. offset_seconds is the time of the AIS fix minus the acquisition time, in seconds, negative for a fix from before it. On a final pair a fix may come from 5-minute data; its time is then up to 4 minutes early, so a fix from just after the acquisition can show a negative offset.
  • sts_detection_limited qualifies what an image says about STS pairs. True when sun glint or haze on this image makes STS pair detection unreliable, so an image without an STS pair is no evidence that no STS took place; detections are unaffected. Null when the image was not measured. Every overflight carries it, in the overflight list and in the overflight result. A find carries none: read it from the overflight that the find names.

ais_state

Every detection carries one ais_state. dark means no AIS was received from a detected vessel of this size although the coverage was measured good; it does not say why. no_match_possible and no_ais_expected are never dark.

ais_stateMeaning
matchedan AIS vessel was matched to the detection
darkno AIS was received from a detected vessel of this size although the coverage was measured good; it does not say why
no_match_possibleno AIS match, and dark could not be decided: the coverage at the detection was not measured good, or no length was predicted
no_ais_expectedno AIS match, and the vessel is predicted shorter than 50 m, a small vessel that need not carry AIS; never dark, whatever the coverage

GET /v1/detection/areas

The areas of sea that are searched, by name. No parameters.

curl -H "Authorization: Bearer $PELYR_KEY" \
  https://api.pelyr.com/v1/detection/areas
{
  "areas": [
    {
      "id": 1,
      "name": "Strait test",
      "polygon": {
        "type": "Polygon",
        "coordinates": [[[56.0, 26.0], [56.4, 26.05], [56.35, 26.3],
                         [56.05, 26.25], [56.0, 26.0]]]
      },
      "south": 26.0, "west": 56.0, "north": 26.3, "east": 56.4,
      "created_at": "2026-10-18T09:00:00Z",
      "latest_sensed_at": "2026-10-22T06:50:21Z"
    }
  ]
}

polygon is a GeoJSON Polygon (WGS 84, positions as [lon, lat]); south, west, north and east are the bounds of its outer ring. latest_sensed_at is the acquisition time of the newest listed overflight, and null before the first. An area whose polygon is invalid is left out. Pelyr enters the areas; this API cannot change them.

GET /v1/detection/areas/{area_id}/overflights

The overflights of one area, newest first, at most 500. An overflight is one satellite pass over the area.

ParameterRequiredNotes
area_id (path)yesAn id from GET /v1/detection/areas.
curl -H "Authorization: Bearer $PELYR_KEY" \
  https://api.pelyr.com/v1/detection/areas/1/overflights
{
  "area_id": 1,
  "overflights": [
    {
      "id": 2, "area_id": 1, "sensed_at": "2026-10-22T06:50:21Z",
      "status": "cloudy", "imaged_percent": 100.0, "cloud_percent": 72.5,
      "ais_slice_status": null, "ais_vessel_count": null,
      "ais_coverage": null, "ais_coverage_share": null,
      "vessel_count": 0, "dark_count": 0, "sts_count": 0, "has_sts": false,
      "sts_detection_limited": null, "processed_at": null
    },
    {
      "id": 1, "area_id": 1, "sensed_at": "2026-10-20T06:50:21Z",
      "status": "processed", "imaged_percent": 100.0, "cloud_percent": 3.5,
      "ais_slice_status": "complete", "ais_vessel_count": 412,
      "ais_coverage": "good", "ais_coverage_share": 0.912,
      "vessel_count": 3, "dark_count": 1, "sts_count": 1, "has_sts": true,
      "sts_detection_limited": false, "processed_at": "2026-10-20T07:38:21Z"
    }
  ]
}
FieldMeaning
sensed_atacquisition time of the image
statusprocessed: an image searched for vessels. cloudy: too cloudy to search, no detections, and the AIS fields are null
imaged_percent, cloud_percentshare of the area with valid image pixels, and the cloud share of that imaged part, in percent
ais_slice_statusthe AIS positions read for the match: complete, truncated (more vessels than one read holds), no_data (no AIS position from anywhere was held within 15 minutes of the acquisition: a reception gap, not an empty sea) or expired (the AIS positions around the acquisition were no longer all held when the image was processed); null without an image
ais_vessel_countAIS vessels read for the match
ais_coverage, ais_coverage_sharethe measured AIS coverage of the area at the acquisition, good or insufficient, and the measure behind it from 0 to 1. dark needs good
vessel_countvessels seen on this overflight: each served detection counts one, but a detection that holds both vessels of a served pair counts two
dark_countof these vessels, those without an AIS match while AIS coverage was good, including the dark partners of such a pair; a vessel predicted shorter than 50 m is never dark
sts_count, has_stsSTS pairs served for this overflight; has_sts is true when sts_count is above 0
sts_detection_limitedsee above
processed_atwhen the image was processed

404 not_found for an area this API does not serve.

GET /v1/detection/overflights/{overflight_id}

One overflight with everything found on it: the overflight as in the list above, its detections by id, and its STS pairs by id.

ParameterRequiredNotes
overflight_id (path)yesAn id from the overflight list.
curl -H "Authorization: Bearer $PELYR_KEY" \
  https://api.pelyr.com/v1/detection/overflights/1

The example is shortened: the box of each detection and pair, the hull_outline and the reasons are left out. reasons are shown in full under GET /v1/detection/finds.

{
  "overflight": {
    "id": 1, "area_id": 1, "sensed_at": "2026-10-20T06:50:21Z",
    "status": "processed", "imaged_percent": 100.0, "cloud_percent": 3.5,
    "ais_slice_status": "complete", "ais_vessel_count": 412,
    "ais_coverage": "good", "ais_coverage_share": 0.912,
    "vessel_count": 3, "dark_count": 1, "sts_count": 1, "has_sts": true,
    "sts_detection_limited": false, "processed_at": "2026-10-20T07:38:21Z"
  },
  "detections": [
    {
      "id": 2, "lon": 56.25, "lat": 26.12, "score": 0.93,
      "length_m": 142.0, "width_m": 24.1, "speed_kn": 0.4,
      "vessel_type": "tanker", "heading_deg": null, "heading_source": null,
      "ais_state": "dark", "coverage_good": true, "faint": false,
      "pair_id": null, "vessels": []
    },
    {
      "id": 5, "lon": 56.2, "lat": 26.1, "score": 0.93,
      "length_m": 236.0, "width_m": 32.2, "speed_kn": 0.4,
      "vessel_type": "tanker", "heading_deg": null, "heading_source": null,
      "ais_state": "matched", "coverage_good": true, "faint": false,
      "pair_id": 1,
      "vessels": [
        {
          "role": "match", "ordinal": 1, "mmsi": 211000003,
          "name": "EXAMPLE TANKER", "ship_type": 80, "flag": "DE",
          "length_m": 183.0, "beam_m": 32.2, "lon": 56.2, "lat": 26.1,
          "fix_at": "2026-10-20T06:48:47Z", "offset_seconds": -94,
          "distance_m": 0.0, "sog_kn": 0.1, "cog_deg": 97.0,
          "heading_deg": 132, "nav_status": 0, "license": "100"
        },
        {
          "role": "match", "ordinal": 2, "mmsi": 211000004,
          "name": "EXAMPLE TANKER", "ship_type": 80, "flag": "DE",
          "length_m": 228.0, "beam_m": 40.0, "lon": 56.2, "lat": 26.1,
          "fix_at": "2026-10-20T06:48:47Z", "offset_seconds": -94,
          "distance_m": 0.0, "sog_kn": 0.2, "cog_deg": 97.0,
          "heading_deg": 132, "nav_status": 0, "license": "103"
        }
      ]
    }
  ],
  "pairs": [
    {
      "id": 1, "detection_id": 5, "state": "preliminary",
      "has_dark_vessel": false,
      "hull_length_m": 236.4, "hull_beam_m": 85.1, "hull_beam_ratio": 0.36,
      "predicted_length_m": 236.0, "predicted_speed": 0.4,
      "scored_at": "2026-10-20T07:38:21Z", "final_at": null
    }
  ]
}

detections

FieldMeaning
lon, lat, boxthe position of the detection and its box, a GeoJSON Polygon
score, faintthe detector's confidence from 0 to 1; faint is true under 0.9
length_m, width_m, speed_kn, vessel_typepredicted from the image, null when there is no estimate. The list of vessel_type values grows
heading_deg, heading_sourcea stated direction, or null for none. model: predicted, at a predicted speed of at least 5 kn. ais: the matched vessel's heading, else its course
ais_state, coverage_goodsee ais_state above; coverage_good is whether the AIS coverage at the detection was measured good
pair_idthe STS pair this detection is part of, or null
vesselsthe AIS vessels stored with the detection, by role and then ordinal

A vessels entry with role: "match" is the vessel the detection was matched to. For an STS pair, the partners are, in partner order: its match rows by ordinal; on a pair without a measured hull, its first match and then its first second_position; on a pair that covers two detections, the first match of each, lower detection id first. role: "second_position" is a further AIS position inside the same hull. Each entry is the vessel as it was at the acquisition: fix_at and offset_seconds say when its position was reported, relative to the acquisition, and distance_m is its distance from the hull or box, 0 inside it. Each carries its license.

pairs

FieldMeaning
state, final_atpreliminary or final; final_at is null until then
reasonsseven entries, one per code, in the order below
has_dark_vessela partner is dark under good AIS coverage
hull_outline, hull_length_m, hull_beam_m, hull_beam_ratiothe hull as measured in the image; null when the pair has no measured hull
predicted_length_m, predicted_speedthe image estimate; null when it gave none
scored_atwhen the reasons were last computed

Each reason has a code, an effect (raises: speaks for an STS transfer, lowers: speaks against it, or none) and a detail object whose keys depend on the code; a null value in it is unknown.

codeWhat it looks at
hull_shapethe hull's width to length, and the detector's confidence
partner_sizesthe partners' sizes from AIS; where there is none, the size estimated from the image (mmsi is then null)
two_ais_positionstwo AIS positions inside one hull
speedthe predicted speed and the partners' reported speeds
dark_partnerdark_count (0, 1 or 2): partners without an AIS match that count toward dark; they are dark only when coverage is good
draught_changethe partners' draught before and after
duration_togetherhow many minutes the two lay together; null when not determinable

404 not_found for an unknown overflight, one that is not listed, and one of an area this API does not serve.

GET /v1/detection/finds

Every STS pair this API serves, across all areas, as a find: newest overflight first, then by pair_id, descending. A find carries its overflight and area, the position of its detection, the pair's state and seven reasons, and its AIS partners.

ParameterRequiredDefaultNotes
area_idnoall areasOnly the finds of this area.
fromno—Acquired at or after, ISO 8601 UTC, must end in Z.
tono—Acquired before, same format; after from.
dark_onlynofalseOnly finds with a dark vessel, which needs AIS coverage measured good.
cursorno—The next_cursor of the previous page, unchanged.
limitno50Page size, 1 to 200.
curl -H "Authorization: Bearer $PELYR_KEY" \
  "https://api.pelyr.com/v1/detection/finds?area_id=1"
{
  "finds": [
    {
      "pair_id": 1, "detection_id": 5, "overflight_id": 1, "area_id": 1,
      "area_name": "Strait test", "sensed_at": "2026-10-20T06:50:21Z",
      "lon": 56.2, "lat": 26.1, "state": "preliminary",
      "has_dark_vessel": false, "hull_length_m": 236.4, "hull_beam_m": 85.1,
      "partners": [
        {"ordinal": 1, "mmsi": 211000003, "name": "EXAMPLE TANKER", "license": "100"},
        {"ordinal": 2, "mmsi": 211000004, "name": "EXAMPLE TANKER", "license": "103"}
      ],
      "reasons": [
        {"code": "hull_shape", "effect": "raises",
         "detail": {"beam_ratio": 0.36, "hull_length_m": 236.4,
                    "hull_beam_m": 85.1, "detector_score": 0.93}},
        {"code": "partner_sizes", "effect": "raises",
         "detail": {"blob_length_m": 236.4, "blob_beam_m": 85.1,
                    "partners": [
                      {"mmsi": 211000003, "length_m": 183.0, "beam_m": 32.2},
                      {"mmsi": 211000004, "length_m": 228.0, "beam_m": 40.0}]}},
        {"code": "two_ais_positions", "effect": "raises",
         "detail": {"mmsis": [211000003, 211000004]}},
        {"code": "speed", "effect": "none",
         "detail": {"predicted_speed": 0.4,
                    "partners": [
                      {"mmsi": 211000003, "sog_kn": 0.1},
                      {"mmsi": 211000004, "sog_kn": 0.2}]}},
        {"code": "dark_partner", "effect": "none",
         "detail": {"dark_count": 0, "coverage": "good"}},
        {"code": "draught_change", "effect": "none",
         "detail": {"partners": [
                      {"mmsi": 211000003, "before_m": 11.2, "after_m": 11.2},
                      {"mmsi": 211000004, "before_m": null, "after_m": null}]}},
        {"code": "duration_together", "effect": "raises",
         "detail": {"minutes": 135}}
      ]
    }
  ],
  "next_cursor": null
}

Page on with cursor = next_cursor until it is null. Finds of one overflight share their acquisition time; the cursor neither repeats nor skips them. A cursor this API did not issue is 400 invalid_cursor; a from or to that is malformed, or a from that is not before to, is 400 invalid_time_range. 404 not_found for an area_id this API does not serve.

lon and lat are the position of the find's detection. partners lists the pair's AIS partners by ordinal; a partner without an AIS match is not listed. The reasons are those of pairs in the overflight result. For the vessel behind a find, read the overflight result with overflight_id.


Zone alerts

Zones, alerts and channels are managed through /v1/alert-zones and /v1/alert-channels; the full request and response schemas are in /openapi.json. A change that would go beyond one of these limits is refused and nothing is saved:

LimitValueWhen exceeded
Zones created through the APIby plan (see pricing); zones created in the portal count against the map plan instead409 alert_zone_limit_reached
Alerts per zone5409 alert_limit_reached
MMSI and IMO numbers per zone, over all its alerts50409 alert_identifier_limit_reached
Zone outlineone ring, at most 50 points, at most 2° of latitude and of longitude, latitude within ±85°, not across the antimeridian422 alert_zone_invalid_geometry
Channels per account (webhooks and Telegram chats, disabled ones included)3409 alert_channel_limit_reached
New channels per account and UTC day, deleted ones included10409 alert_daily_limit_reached
Test messages per account and UTC day20409 alert_daily_limit_reached

A Telegram chat beyond the channel limits is not linked; the bot says so in the chat.

Each channel receives at most 60 messages per UTC clock hour, test messages excluded. Further alerts of that hour are not sent; at the start of the next hour the channel gets one message with their number and a link to the event log, which keeps events for 30 days. A channel is switched off after 20 failed delivery attempts in a row, and at once when a webhook answers 410, a Telegram chat sends /stop, or the bot is blocked or removed from the chat; disabled_reason names which. Setting state to active switches it on again.

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/at malformed, missing Z, from >= to, or at in the future
invalid_cursor400cursor of GET /v1/detection/finds is not a next_cursor this API returned
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
history_outside_plan403Track resolution or from outside your plan's history
time_slice_not_in_plan403/v1/vessels/at is not in your plan (Free)
detection_not_in_plan403/v1/detection/ routes are not in your plan (Free)
archive_not_enabled403Archive needed, key not unlocked
not_found404Vessel silent for over an hour; vessel detection: an area or overflight that does not exist or is not served
bad_request400Vessel search: none or more than one of mmsi, imo, name; an IMO number with a wrong check digit; a name without letters or digits
bad_request422Parameter fails schema validation
rate_limited429Over the rate limit; see Retry-After
internal_error500Unhandled server error
track_unavailable503at before the retained minute history; or a track range that needs the archive, which is not queryable yet

Notes:

  • Code and status are not one-to-one. Six different codes return 403, and bad_request comes as 400 or 422. 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.