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.
| 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. 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:
| Limit | Value | When exceeded |
|---|---|---|
| Active keys per account | by 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 count | 400 key_limit_reached |
| New keys | at most 5 key creation requests per hour per account, failed ones included | 429 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:
| Endpoints | Limit |
|---|---|
/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/finds | the per-minute limit of your plan; Free plan: 2 per minute per API key |
/v1/vessels/{mmsi}/track | the per-minute limit of your plan; Free plan: 2 per minute per API key |
/v1/sources | 1 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.
| 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. 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
| 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 |
plausibility | unknown, high, medium or low — see below | no |
flags | why plausibility is not high, see below; empty for high and unknown | no |
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.
plausibility | Meaning |
|---|---|
high | nothing speaks against this position |
medium | something does not add up; see flags |
low | this position is unlikely to be real; see flags |
unknown | not assessed yet |
| Flag | Meaning |
|---|---|
implausible_speed | the ship was moving impossibly fast |
unreconciled_track | the ship's track is not plausible |
invalid_mmsi | the MMSI is not a valid identifier |
speed_mismatch | the ship moves much faster than it reports |
course_mismatch | the ship moves in a different direction than it reports |
moving_while_moored | the 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:
| Sender | MMSI structure |
|---|---|
| Ship | MID + 6 digits |
| Aid to navigation | 99 + MID + 4 digits |
| SAR aircraft | 111 + MID + 3 digits |
| SART, MOB, EPIRB | 970, 972 or 974 + 6 digits |
| Handheld VHF with GNSS | 8 + MID + 5 digits |
| Craft associated with a parent ship | 98 + 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.
| 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 three differences:
tsis the start of the bucket on the clock, not the reception time. At1hit is the full hour, at1dmidnight UTC.msg_typeis nevernull.plausibilityandflagsare 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:
| Resolution | Max range per request | Directly queryable back to |
|---|---|---|
1min | 7 days | 48 hours |
5min | 30 days | 30 days |
15min | 90 days | 60 days |
1h | 365 days | 1825 days (5 years) |
6h | 1825 days (5 years) | 3650 days (10 years) |
1d | unlimited | unlimited |
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.
| Parameter | Required | Default | Notes |
|---|---|---|---|
at | yes | — | 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). |
bbox | yes | — | 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.
| Parameter | Required | Default | Notes |
|---|---|---|---|
mmsi | one of the three | — | 100000000 to 999999999. |
imo | one of the three | — | Seven digits with a valid check digit. |
name | one of the three | — | A current or former vessel name. |
max | no | 10 | 1 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
| Field | Meaning |
|---|---|
kind | exact, prefix (the name starts with your query) or similar (spelled alike) |
field | what matched: mmsi, imo, name (the current name) or former_name |
matched_name | the name that matched, as last seen; for mmsi and imo the current name, or null if none is known |
score | 0 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_atodim_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_seenis the start of our observation, not the date the vessel was built or named.tsandlicensedescribe 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 onGET /v1/vessels, includingdest,etaanddraught. It is present only while the vessel is transmitting.position— the fields ofpositionabove, pluslive.live: trueis the current position, withplausibilityandflags.live: falseis the last known position from the past 30 days.sog,cog,heading,nav_statusandrotare the values the vessel reported atts, not its current state. There is noplausibilityand noflags.- No position in the past 30 days:
positionis 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
reasonssay what speaks for an STS transfer and what against it. - A pair is
preliminaryuntil its one re-scoring. It is published aspreliminaryand re-scored once, when AIS from after the acquisition has arrived; after that it isfinal, 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 thelicenseof its reported AIS position, a string as everywhere in this API: resolve it withGET /v1/sourcesand 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_secondsis the time of the AIS fix minus the acquisition time, in seconds, negative for a fix from before it. On afinalpair 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_limitedqualifies 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_state | Meaning |
|---|---|
matched | an AIS vessel was matched to the detection |
dark | 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 | no AIS match, and dark could not be decided: the coverage at the detection was not measured good, or no length was predicted |
no_ais_expected | no 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.
| Parameter | Required | Notes |
|---|---|---|
area_id (path) | yes | An 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"
}
]
}
| Field | Meaning |
|---|---|
sensed_at | acquisition time of the image |
status | processed: an image searched for vessels. cloudy: too cloudy to search, no detections, and the AIS fields are null |
imaged_percent, cloud_percent | share of the area with valid image pixels, and the cloud share of that imaged part, in percent |
ais_slice_status | the 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_count | AIS vessels read for the match |
ais_coverage, ais_coverage_share | the 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_count | vessels seen on this overflight: each served detection counts one, but a detection that holds both vessels of a served pair counts two |
dark_count | of 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_sts | STS pairs served for this overflight; has_sts is true when sts_count is above 0 |
sts_detection_limited | see above |
processed_at | when 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.
| Parameter | Required | Notes |
|---|---|---|
overflight_id (path) | yes | An 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
| Field | Meaning |
|---|---|
lon, lat, box | the position of the detection and its box, a GeoJSON Polygon |
score, faint | the detector's confidence from 0 to 1; faint is true under 0.9 |
length_m, width_m, speed_kn, vessel_type | predicted from the image, null when there is no estimate. The list of vessel_type values grows |
heading_deg, heading_source | a 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_good | see ais_state above; coverage_good is whether the AIS coverage at the detection was measured good |
pair_id | the STS pair this detection is part of, or null |
vessels | the 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
| Field | Meaning |
|---|---|
state, final_at | preliminary or final; final_at is null until then |
reasons | seven entries, one per code, in the order below |
has_dark_vessel | a partner is dark under good AIS coverage |
hull_outline, hull_length_m, hull_beam_m, hull_beam_ratio | the hull as measured in the image; null when the pair has no measured hull |
predicted_length_m, predicted_speed | the image estimate; null when it gave none |
scored_at | when 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.
code | What it looks at |
|---|---|
hull_shape | the hull's width to length, and the detector's confidence |
partner_sizes | the partners' sizes from AIS; where there is none, the size estimated from the image (mmsi is then null) |
two_ais_positions | two AIS positions inside one hull |
speed | the predicted speed and the partners' reported speeds |
dark_partner | dark_count (0, 1 or 2): partners without an AIS match that count toward dark; they are dark only when coverage is good |
draught_change | the partners' draught before and after |
duration_together | how 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.
| Parameter | Required | Default | Notes |
|---|---|---|---|
area_id | no | all areas | Only the finds of this area. |
from | no | — | Acquired at or after, ISO 8601 UTC, must end in Z. |
to | no | — | Acquired before, same format; after from. |
dark_only | no | false | Only finds with a dark vessel, which needs AIS coverage measured good. |
cursor | no | — | The next_cursor of the previous page, unchanged. |
limit | no | 50 | Page 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:
| Limit | Value | When exceeded |
|---|---|---|
| Zones created through the API | by plan (see pricing); zones created in the portal count against the map plan instead | 409 alert_zone_limit_reached |
| Alerts per zone | 5 | 409 alert_limit_reached |
| MMSI and IMO numbers per zone, over all its alerts | 50 | 409 alert_identifier_limit_reached |
| Zone outline | one ring, at most 50 points, at most 2° of latitude and of longitude, latitude within ±85°, not across the antimeridian | 422 alert_zone_invalid_geometry |
| Channels per account (webhooks and Telegram chats, disabled ones included) | 3 | 409 alert_channel_limit_reached |
| New channels per account and UTC day, deleted ones included | 10 | 409 alert_daily_limit_reached |
| Test messages per account and UTC day | 20 | 409 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.
| 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/at malformed, missing Z, from >= to, or at in the future |
invalid_cursor | 400 | cursor of GET /v1/detection/finds is not a next_cursor this API returned |
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 |
history_outside_plan | 403 | Track resolution or from outside your plan's history |
time_slice_not_in_plan | 403 | /v1/vessels/at is not in your plan (Free) |
detection_not_in_plan | 403 | /v1/detection/ routes are not in your plan (Free) |
archive_not_enabled | 403 | Archive needed, key not unlocked |
not_found | 404 | Vessel silent for over an hour; vessel detection: an area or overflight that does not exist or is not served |
bad_request | 400 | Vessel 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_request | 422 | Parameter fails schema validation |
rate_limited | 429 | Over the rate limit; see Retry-After |
internal_error | 500 | Unhandled server error |
track_unavailable | 503 | at 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_requestcomes as 400 or 422. 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.