Vessels API (AIS)
Query the AIS vessel directory and live vessel detail. Both endpoints are read-only and require the ais:read permission.
This is a query-only API over two static/near-live sources — there is no position-history / track endpoint. Live positions are never persisted to Postgres; they stream over the WebSocket "vessels" channel instead (see WebSocket API) and are cached in Redis for the detail read below. A third, separate endpoint (below) exposes the static-data change-log per MMSI — name/callsign/ship-type/length/beam changes, not positions.
Data Sources
| Endpoint | Source | Position fields (lat/lng/heading/course/speedKmh) | navStatus | firstSeen |
|---|---|---|---|---|
Directory (GET /v1/vessels / vessels) | Postgres "Vessel" registry (static, durable catalog of every vessel ever seen) | always null | always null | from the registry row |
Detail (GET /v1/vessels/{mmsi} / vessel(mmsi)) | Redis ais:vessel:latest:{mmsi} first (full live snapshot written by the AIS ingest pipeline); falls back to the registry (position fields null) on a cache miss | present on a Redis hit, null on registry fallback | present on a Redis hit, null on registry fallback | merged in from a registry lookup by mmsi regardless of Redis hit/miss; null only if the vessel has no registry row |
The registry is a static catalog only (name, callsign, ship type, length, beam, source, first/last-seen) — it has no position column. Live positions live exclusively in the Redis hot cache and the "vessels" WebSocket broadcast; the Redis key is cleared (not just expired) by the AIS writer task once a vessel stops reporting, at which point detail reads fall back to the registry. firstSeen lives ONLY in the registry — Redis has no such field — so a detail read always does a lightweight registry lookup by mmsi to populate it, even on a Redis hit.
Canonical Vessel Shape
REST (VesselView, snake_case) and GraphQL (GqlVessel, camelCase) expose the same fields:
| REST field (snake_case) | GraphQL field (camelCase) | Type | Description |
|---|---|---|---|
id | id | UUID | null | Registry-only stable identifier, same presence rule as first_seen/firstSeen (merged in from the registry on a detail Redis hit); null only if the vessel was never registered |
mmsi | mmsi | integer | Maritime Mobile Service Identity |
name | name | string | May be empty when the source never reported a name |
callsign | callsign | string | null | |
ship_type | shipType | integer | null | Numeric ITU ship-type code |
nav_status | navStatus | integer | null | Numeric nav-status code; null on registry fallback |
length | length | integer | null | Meters |
beam | beam | integer | null | Meters |
lat | lat | number | null | null on registry fallback |
lng | lng | number | null | null on registry fallback |
heading | heading | integer | null | Degrees; null on registry fallback |
course | course | number | null | Degrees; null on registry fallback |
speed_kmh | speedKmh | number | null | null on registry fallback |
source | source | string | Data source identifier |
last_seen | lastSeen | timestamp | ISO 8601 |
first_seen | firstSeen | timestamp | null | ISO 8601. Registry-only — populated whenever the vessel has a row in the "Vessel" registry, including on a detail Redis hit (merged in from a registry lookup by mmsi); null only if the vessel was never registered |
REST API
List Vessels
Paginated vessel directory, sourced from the static registry.
GET /v1/vessels
Authorization: Bearer YOUR_TOKEN
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number (1-indexed) |
limit | integer | 20 | Items per page (clamped 1–100) |
name | string | - | Case-insensitive substring match on vessel name |
ship_type | integer | - | Exact numeric ITU ship-type code |
Response:
{
"data": [
{
"id": "32b2226c-18f3-452e-9d1f-fab5128110df",
"mmsi": 211234560,
"name": "MS RENA",
"callsign": "DABC",
"ship_type": 70,
"nav_status": null,
"length": 100,
"beam": 20,
"lat": null,
"lng": null,
"heading": null,
"course": null,
"speed_kmh": null,
"source": "local",
"last_seen": "2026-07-28T10:00:00Z",
"first_seen": "2026-01-15T08:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"total_pages": 1
},
"_links": {
"self": "https://api.example.com/v1/vessels?page=1&limit=20"
}
}
Get Vessel
Vessel detail: Redis hot-read first (full live data), falls back to the static registry on a cache miss.
GET /v1/vessels/{mmsi}
Authorization: Bearer YOUR_TOKEN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
mmsi | integer | Vessel MMSI |
Response (Redis hit — live position present):
{
"data": {
"id": "32b2226c-18f3-452e-9d1f-fab5128110df",
"mmsi": 211234560,
"name": "MS RENA",
"callsign": "DABC",
"ship_type": 70,
"nav_status": 0,
"length": 100,
"beam": 20,
"lat": 49.4,
"lng": 8.6,
"heading": 90,
"course": 88.0,
"speed_kmh": 18.5,
"source": "local",
"last_seen": "2026-07-28T10:00:00Z",
"first_seen": "2026-01-15T08:30:00Z"
},
"_links": {
"self": "https://api.example.com/v1/vessels/211234560"
}
}
Error Responses:
| Status | Description |
|---|---|
| 401 | Unauthorized — invalid or missing token |
| 403 | Forbidden — missing ais:read permission |
| 404 | Vessel not found in either Redis or the registry |
Get Vessel Static History
Paginated static-data change-log for a single MMSI, sourced from the append-only "VesselStaticHistory" TimescaleDB hypertable (DESC by recorded_at). One row per real static-data change (rename, dimension correction, ship-type change) — diffed against the registry so API restarts don't create duplicate rows. This is not a position/track endpoint; it never contains lat/lng. Shown on the webapp vessel detail page (/ais/vessels/{mmsi}) as the "Static History" table.
GET /v1/vessels/{mmsi}/history?page=1&limit=20
Authorization: Bearer YOUR_TOKEN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
mmsi | integer | Vessel MMSI |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number (1-indexed) |
limit | integer | 20 | Items per page |
VesselHistoryView (REST, snake_case) / GqlVesselHistoryEntry (GraphQL, camelCase):
| REST field (snake_case) | GraphQL field (camelCase) | Type | Description |
|---|---|---|---|
id | id | UUID | History entry ID |
mmsi | mmsi | integer | Vessel MMSI |
name | name | string | null | Vessel name at the time of the change |
callsign | callsign | string | null | |
ship_type | shipTypeCode | integer | null | Numeric ITU ship-type code. Note: the REST field is named ship_type (matching VesselHistoryView), while GraphQL names it shipTypeCode — an intentional naming difference between the two surfaces for this endpoint only |
length | length | integer | null | Meters |
beam | beam | integer | null | Meters |
source | source | string | Data source identifier |
device_id | deviceId | string | null | Reporting device ID, if known |
recorded_at | recordedAt | timestamp | ISO 8601. Vessel/event time of the change, not wall-clock write time |
Response:
{
"data": [
{
"id": "32b2226c-18f3-452e-9d1f-fab5128110df",
"mmsi": 211234560,
"name": "MS RENA",
"callsign": "DABC",
"ship_type": 70,
"length": 100,
"beam": 20,
"source": "local",
"device_id": null,
"recorded_at": "2026-07-28T10:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"total_pages": 1
}
}
Error Responses:
| Status | Description |
|---|---|
| 400 | Invalid MMSI |
| 401 | Unauthorized — invalid or missing token |
| 403 | Forbidden — missing ais:read permission |
An unknown or never-changed MMSI returns an empty page (data: [], total: 0) — not a 404.
GraphQL API
Query: vessels
Paginated vessel directory.
query {
vessels(filter: { name: "rena", shipTypeCode: 70 }, page: 1, limit: 20) {
data {
id
mmsi
name
callsign
shipType
navStatus
length
beam
lat
lng
heading
course
speedKmh
source
lastSeen
firstSeen
}
total
page
limit
totalPages
}
}
VesselFilterInput:
| Field | Type | Description |
|---|---|---|
name | String | Case-insensitive substring match on vessel name |
shipTypeCode | Int | Exact numeric ITU ship-type code |
shipTypeCodeMin | Int | Inclusive lower bound of a ship-type code range (base-category filter, e.g. Cargo = 70) |
shipTypeCodeMax | Int | Inclusive upper bound of the ship-type code range (e.g. Cargo = 79). Combined with shipTypeCodeMin this selects a whole category; the backend AND-combines all filters |
The REST endpoint GET /v1/vessels mirrors these as query params ship_type_code_min / ship_type_code_max (alongside the exact ship_type). The backend web console's AIS directory uses this range filter to offer a ship-type category dropdown (Cargo, Tanker, Sailing, Tug, …).
Query: vessel
Vessel detail by MMSI. Returns null if the vessel is in neither Redis nor the registry.
query {
vessel(mmsi: 211234560) {
id
mmsi
name
lat
lng
navStatus
speedKmh
lastSeen
firstSeen
}
}
Query: vesselHistory
Paginated static-data change-log for a single MMSI. See Get Vessel Static History above for the field mapping and semantics — the GraphQL query is a straight mirror of the REST endpoint.
query {
vesselHistory(mmsi: 211234560, page: 1, limit: 20) {
data {
id
mmsi
name
callsign
shipTypeCode
length
beam
source
deviceId
recordedAt
}
total
page
limit
totalPages
}
}
vesselHistory(mmsi: Int!, page: Int! = 1, limit: Int! = 20): PaginatedVesselHistory! — PaginatedVesselHistory mirrors PaginatedVessel (data, total, page, limit, totalPages).
Required Permission
The REST endpoints and the GraphQL queries (including vesselHistory) require ais:read on the caller's auth context. There is no separate write surface exposed here — vessel data is written internally by the AIS ingest pipeline, not through this API.
See Also
- WebSocket API — the
"vessels"channel broadcasts live position updates (the same data that feeds the Redis hot cache used byGET /v1/vessels/{mmsi}) - AIS Vessel Tracking — architecture overview, ingest pipeline, and the webapp detail page that renders this history