Notifications API
Subscribe to vessel events via webhooks or WebSocket. Get notified when vessels arrive at ports, move, enter geofences, or update their ETA, without polling the API. Two flavors are available: standard notifications for explicit vessel lists, and advanced notifications for polygon geofencing with attribute filters.
Overview
The Notifications API lets you create event-driven subscriptions. Instead of repeatedly polling endpoints, you define a notification specifying what to watch, which event types to listen for, and where to deliver events. VesselAPI then pushes matching events to your webhook URL, your WebSocket connection, or both.
Pick the flavor that matches your use case:
| Standard | Advanced | |
|---|---|---|
| Vessel targeting | Explicit list of up to 100 IMOs/MMSIs | Every vessel in a polygon (any_vessel), or a list of up to 10,000 vessels (vessel_list) |
| Geofence shape | Single bounding box | Polygon with up to 5,000 points |
| Attribute filters | None | Filter by vessel type, subtype, length, ENI |
| Event types | All 11 event types | Geofence enter/exit; plus ETA, destination, and draught changes in vessel_list mode |
| Identified by | Server-generated UUID | Your chosen name |
| Plans | All paid plans (see limits below) | Pro only |
Plans and limits
| Plan | Active standard notifications | Advanced notifications |
|---|---|---|
| Free | Not included | Not included |
| Basic | 3 | Not included |
| Starter | 5 | Not included |
| Growth | 10 | Not included |
| Pro | Unlimited | Up to 50 |
Standard limits count active notifications: pausing one (active: false) frees up a slot. The advanced limit of 50 counts all advanced notifications regardless of active state: delete the ones you no longer need. Each standard notification can watch up to 100 vessels. View pricing for plan details.
Latency model: events are produced by polling the AIS data store, not by a per-message stream. Standard notifications are evaluated roughly every 5 minutes; advanced notifications roughly every 60-90 seconds. Each event carries two timestamps: timestamp (when the underlying AIS message or port event was reported) and detectedAt (when VesselAPI detected the change).
Quick Start
Get your API key
Sign up at vesselapi.com/pricing and grab your Bearer token from the dashboard.
Create a notification
POST to /notifications with the vessels to watch, or to /notifications/advanced with a polygon (Pro).
Receive events
VesselAPI pushes JSON event envelopes to your webhook (verify the signature, respond 2xx) or your WebSocket connection. Use POST .../test to prove the pipe end to end.
Event Types
Events are organized into four categories. Subscribe to individual event types via eventTypes, or leave it empty to receive all events the notification supports. Advanced notifications support a subset of these types: see the compatibility matrix.
| Category | Event Type | Fires when |
|---|---|---|
| Port Events | port.arrival | The vessel arrived at a port |
| port.departure | The vessel departed a port | |
| ETA / Voyage Changes | eta.destination_changed | The reported destination changed to a new, non-empty value |
| eta.eta_changed | The reported ETA shifted by at least etaShiftThresholdMinutes (default 120) |
|
| eta.draught_changed | The reported draught changed by more than 0.1 m (loading/unloading indicator) | |
| Position Changes | position.nav_status_changed | The AIS navigational status changed (e.g. underway to anchored) |
| position.speed_changed | Speed over ground crossed the stopped/moving boundary set by speedThresholdKnots (default 0.5 kn): the vessel started or stopped moving |
|
| position.update | Broad: a new AIS report changed any tracked field (coordinates, speed, course, heading, or nav status). For a moving vessel this fires on essentially every report. | |
| position.position_changed | Specific: the geographic coordinates changed. Filters out reports where the vessel did not move. | |
| Geofence | position.geofence_enter | The vessel entered the notification's geofence (bounding box on standard, polygon on advanced) |
| position.geofence_exit | The vessel left the notification's geofence |
position.update vs position.position_changed
position.update fires whenever a new AIS report differs from the previous one in any tracked field, so a vessel can be stationary and still emit updates when its speed, heading, or nav status fluctuates. position.position_changed fires only when the coordinates themselves changed. A moving vessel trips both on the same report, so subscribing to both roughly doubles your event volume: pick one.
The test event type: the POST .../test endpoints deliver a frame with type: "test" and a data.message string through your configured channels. It cannot be subscribed to; make sure your consumer tolerates it.
Delivery Methods
Both flavors deliver through the same two channels. You can enable either or both on the same notification; an event delivered to both channels is still one event.
Webhooks
When an event matches, VesselAPI sends an HTTP POST to your webhookUrl with the event envelope as JSON.
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Signature-256 | sha256=<hex>: HMAC-SHA256 of the raw request body, keyed with your webhookSecret |
| X-Event-Type | The event type string (e.g. position.geofence_enter), useful for routing before parsing the body |
| X-Delivery-ID | Equals event.id and stays the same across retries. The same event fans out to each of your matching notifications under the same id, so deduplicate on the pair (X-Delivery-ID, notificationId) |
- Your endpoint must respond with a
2xxstatus within 10 seconds. - Failed deliveries are retried up to 3 times with exponential backoff (1s, 2s, 4s).
- After all retries are exhausted, the event is dropped. During extreme bursts events can also be dropped before the first attempt if the internal delivery queue fills. There is no delivery history endpoint; if you cannot miss events, make your receiver highly available and idempotent.
- Delivery order across events is not guaranteed (deliveries run in parallel and retries reorder them). Order by
event.timestampon your side. - The webhook URL must be a public http(s) address. Localhost names, private and link-local IP literals, and cloud metadata hosts are rejected with 400 at create time; a private hostname passes creation but every delivery to it is blocked at send time and fails silently. Either way you cannot reach an internal-only endpoint: for local development use the WebSocket channel or a tunnel.
HMAC Signature Verification
Always verify the X-Signature-256 header to confirm events originate from VesselAPI:
import hmac
import hashlib
def verify_signature(payload_body: bytes, secret: str, signature: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
payload_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
WebSocket
For live dashboards and streaming consumers, enable WebSocket delivery with websocket: true. Each notification has its own stream:
# Standard notifications (by id)
wss://api.vesselapi.com/v1/ws?notification_id=YOUR_NOTIFICATION_ID
# Advanced notifications (by name, URL-encoded)
wss://api.vesselapi.com/v1/ws/advanced?name=YOUR_NOTIFICATION_NAME
Authentication is done via the Authorization: Bearer header on the HTTP upgrade request, the same API key as the REST API. There is no subscribe protocol: the query parameter at connect time is the subscription, and anything you send to the server is ignored (frames larger than 16 KB close the connection).
| Detail | Value |
|---|---|
| Message format | JSON text frames, one complete EventEnvelope per frame, same structure as webhooks |
| Keepalive | The server pings every 30 seconds. Standard WebSocket libraries answer pongs automatically; a connection that stops answering is dropped after roughly 40 seconds. |
| Connection limit | One connection per notification. Opening a new connection for the same notification replaces the previous one, so do not run two consumers on one notification. The server also caps total concurrent connections at 1,000; beyond that, new connections are closed right after the upgrade. |
| Server restarts | On deploys the server sends close code 1012 (service restart). Reconnect immediately when you receive it. |
| Missed events | Events produced while you are disconnected are discarded. There is no replay. If you cannot miss events, configure a webhook (optionally alongside the WebSocket). |
| Slow consumers | Each connection has a 256-event send buffer; if your consumer falls behind, overflowing events are dropped. |
| Reconnection | Reconnect immediately on close code 1012; use exponential backoff (e.g. 1s doubling to 30s) for other failures. |
Browser note: The browser WebSocket API does not support custom headers, so browsers cannot connect directly. Proxy through your backend instead. For server-side clients, use a library that supports headers (e.g., ws for Node.js, websockets for Python, java.net.http.WebSocket on the JVM).
WebSocket Client (Node.js)
import WebSocket from 'ws';
const ws = new WebSocket(
'wss://api.vesselapi.com/v1/ws?notification_id=YOUR_NOTIFICATION_ID',
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
ws.on('open', () => console.log('Connected to event stream'));
ws.on('message', (data) => {
const envelope = JSON.parse(data);
const event = envelope.event;
console.log(`[${event.type}] ${event.vessel.vesselName}`, event.data);
});
ws.on('close', (code, reason) => {
console.log(`Disconnected: ${code} ${reason}`);
// code 1012 = server restart: reconnect immediately.
// Otherwise reconnect with exponential backoff.
});
ws.on('error', (err) => console.error('WebSocket error:', err));
WebSocket Client (Python)
import asyncio
import json
import websockets
async def listen():
uri = "wss://api.vesselapi.com/v1/ws?notification_id=YOUR_NOTIFICATION_ID"
# websockets >= 14 uses additional_headers; older versions use extra_headers
headers = {"Authorization": "Bearer YOUR_API_KEY"}
async with websockets.connect(uri, additional_headers=headers) as ws:
print("Connected to event stream")
async for message in ws:
envelope = json.loads(message)
event = envelope["event"]
print(f"[{event['type']}] {event['vessel']['vesselName']}")
asyncio.run(listen())
Standard Notifications
A standard notification watches an explicit list of vessels. The following fields are available when creating (POST /notifications) or updating (PUT /notifications/{id}, only provided fields are changed).
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | No | Human-readable label for this configuration. Optional but strongly recommended: it is your only handle besides the generated id. |
| imos | integer[] | * | IMO numbers to watch, as JSON numbers, not strings (max 100 combined with mmsis) |
| mmsis | integer[] | * | MMSI numbers to watch, as JSON numbers (max 100 combined with imos) |
| eventTypes | string[] | No | Event types to subscribe to (see table above). Empty or omitted means all event types. |
| webhookUrl | string | ** | Public HTTP(S) URL to receive event payloads (private/localhost addresses are rejected) |
| webhookSecret | string | Conditional | HMAC-SHA256 secret for signing webhook payloads. Required when webhookUrl is set. Never returned in responses. |
| websocket | boolean | No | Enable WebSocket delivery (default: false) |
| geofence | object | No | Bounding box for geofence events: lonLeft, lonRight, latBottom, latTop (latBottom must be less than latTop). Needed for polygon shapes? Use advanced notifications. |
| speedThresholdKnots | number | No | Stopped/moving boundary for position.speed_changed (default 0.5) |
| etaShiftThresholdMinutes | integer | No | Minimum ETA shift in whole minutes to trigger eta.eta_changed (default 120) |
| active | boolean | Update only | Pause (false) or resume (true) event delivery without deleting the configuration |
* At least one of imos or mmsis is required. ** At least one delivery method (webhookUrl or websocket: true) is required. Both rules are enforced at create time only: updates apply the provided fields as-is, so avoid removing the last vessels or the last delivery channel via PUT (the update is accepted and the notification simply goes silent).
Threshold caveat: change detection runs once per vessel across all notifications watching it. If several of your notifications watch the same vessel with different speedThresholdKnots or etaShiftThresholdMinutes, one shared evaluation (the first configured threshold) decides whether the event fires for all of them. Per-notification thresholds are only guaranteed when a single notification watches the vessel.
Create responds 201 with the notification wrapped in an envelope: {"notification": {"id": "...", ...}}. Keep the id: it identifies the notification in every other call and in the WebSocket URL.
Warm-up: standard change detection is stateful per vessel, and the state lives in service memory. The first report seen for a vessel after you create a notification, or after a service restart, only establishes the baseline and emits nothing: events start from the second report onwards. Changes that span a restart can be missed on both channels.
Configuration Examples
The difference between delivery channels is simply which fields you set. Use webhookUrl + webhookSecret for webhook delivery, websocket: true for WebSocket delivery, or set both on the same notification to receive events on both channels simultaneously.
Webhook Only
curl -X POST "https://api.vesselapi.com/v1/notifications" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Fleet alerts",
"imos": [9321483, 9472440],
"webhookUrl": "https://example.com/webhook",
"webhookSecret": "my-hmac-secret",
"eventTypes": ["port.arrival", "port.departure", "position.position_changed"]
}'
WebSocket Only
curl -X POST "https://api.vesselapi.com/v1/notifications" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Live tracking",
"imos": [9321483, 9472440],
"websocket": true,
"eventTypes": ["position.position_changed"]
}'
Then connect to the WebSocket endpoint with the returned notification ID:
wss://api.vesselapi.com/v1/ws?notification_id=<id>
# With header: Authorization: Bearer YOUR_API_KEY
API Quota: for standard notifications, every event dispatched to your webhook (counted when the delivery is queued, even if your endpoint ends up rejecting it) or delivered to a connected WebSocket client counts as 1 API call against your monthly quota; an event going to both channels still counts as 1 call, not 2. Events that arrive while no WebSocket client is connected are discarded and not billed. Each authenticated HTTP request, including the WebSocket connect itself, also counts as 1 call. When your monthly quota is exhausted, standard event delivery pauses until the period resets. Advanced notifications require the Pro plan, whose usage is unlimited.
Advanced Notifications PRO
Advanced notifications monitor a polygon geofence instead of a bounding box, and can match every vessel inside it, optionally narrowed by attribute filters, without naming vessels up front. They live under /notifications/advanced and are identified by the name you choose (1-64 characters: letters, digits, underscore, hyphen, dot, space; URL-encode spaces in paths). They require the Pro plan; you can have up to 50 per account.
Creating an advanced notification
curl -X POST "https://api.vesselapi.com/v1/notifications/advanced" \
-H "Authorization: Bearer YOUR_PRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "rotterdam_inland_watch",
"mode": "any_vessel",
"polygon": {
"coordinates": [
[4.00, 51.85], [4.60, 51.85], [4.60, 52.05], [4.00, 52.05], [4.00, 51.85]
]
},
"filterGroups": [
{ "predicates": [ { "field": "eni", "op": "present" } ] }
],
"hysteresisMeters": 500,
"websocket": true,
"eventTypes": ["position.geofence_enter", "position.geofence_exit"]
}'
The response is 201 with the notification wrapped in an envelope:
{
"notification": {
"name": "rotterdam_inland_watch",
"active": true,
"mode": "any_vessel",
"polygon": { "coordinates": [ ... ] },
"filterGroups": [ { "predicates": [ { "field": "eni", "op": "present" } ] } ],
"hysteresisMeters": 500,
"websocket": true,
"eventTypes": ["position.geofence_enter", "position.geofence_exit"],
"prefillStatus": "pending",
"createdAt": "2026-06-10T12:00:00Z",
"updatedAt": "2026-06-10T12:00:00Z"
}
}
The envelope matters: every single-object response wraps the payload under a top-level "notification" key (lists use "notifications"). A deserializer that expects the fields at the top level will silently read nulls.
Modes
| Mode | Matches | Notes |
|---|---|---|
| any_vessel | Every vessel inside the polygon that passes the filters | No vessel list needed. Supports geofence enter/exit events only. |
| vessel_list | Only vessels on the vessels allowlist (max 10,000) |
Each entry must include an mmsi (IMO alone is rejected: matching state is keyed by MMSI). Additionally supports the three eta.* event types. |
mode and name are immutable after create: to change them, delete and recreate.
Supported event types per mode
Advanced notifications emit a narrower event vocabulary than standard ones. Requesting an unsupported combination fails with 400 at create/update time, so a subscription that could never fire is rejected instead of staying silent.
| Event type | any_vessel | vessel_list |
|---|---|---|
| position.geofence_enter | Yes | Yes |
| position.geofence_exit | Yes | Yes |
| eta.destination_changed | No (400) | Yes |
| eta.eta_changed | No (400) | Yes |
| eta.draught_changed | No (400) | Yes |
| port.*, position.update, position.position_changed, position.nav_status_changed, position.speed_changed | No (400) | No (400): use a standard notification |
- An empty
eventTypeslist means all event types the mode supports. eta.*events invessel_listmode fire only while the vessel's latest position (reported within the last hour) is inside the polygon, and they also fire on the first observed value (with an empty/zeroprevious) so you get a baseline. After the baseline,eta.eta_changedfires only for shifts of at leastetaShiftThresholdMinutes(default 120) andeta.draught_changedonly for changes greater than 0.1 m.- To combine area monitoring with port events or continuous position updates, pair an advanced notification with a standard one.
Polygon
- A single closed ring of
[longitude, latitude]pairs (WGS84); first and last point identical; 4 to 5,000 points; no holes or multipolygons. - Edges crossing the antimeridian (180° longitude) are rejected: split such areas into two notifications.
- The server repairs and simplifies the polygon at roughly 100 m tolerance on write. The stored, simplified polygon is what matching uses and what GET returns: read it back after create if exact geometry matters to you.
hysteresisMeters(0-10,000, default 50) is an exit debounce: a vessel must be at least this far outside the polygon beforegeofence_exitfires, which prevents enter/exit flapping for vessels loitering on the boundary.
Attribute filters (filterGroups)
Filters narrow which vessels match, evaluated against the vessel's static record. Predicates within a group are combined with AND; groups are combined with OR. An empty filterGroups matches all vessels. Limits: 20 groups, 20 predicates per group, 200 values per in list.
| Field | Operators | Example |
|---|---|---|
| vesselType | eq, in, present |
{"field":"vesselType","op":"in","values":["Cargo","Tanker"]} |
| vesselSubtype | eq, in, present |
{"field":"vesselSubtype","op":"eq","value":"Bulk carrier"} |
| length | eq, gte, lte |
{"field":"length","op":"gte","value":80} |
| eni | eq, present |
{"field":"eni","op":"present"} |
present means the attribute is known (non-null): for example eni present selects European inland vessels with a registered ENI number. Attribute coverage varies by fleet; inland-vessel attributes like ENI and subtype are populated for the better-covered part of the fleet, so treat filters on them as selecting "vessels known to have X" rather than a guarantee of completeness.
The prefill lifecycle
When you create an advanced notification (or change its polygon/filters), vessels may already be inside the area. Without preparation, every one of them would emit a spurious geofence_enter on the first poll. Prefill solves this: a background job marks all currently-inside vessels as "inside" without emitting events, so you only receive genuine boundary crossings afterwards.
| prefillStatus | Meaning |
|---|---|
| pending | Queued for prefill. No events are produced yet; WebSocket connects are refused with 409. |
| running | Prefill in progress (large polygons can take minutes). |
| ready | Live. Events flow and WebSocket connects are accepted. |
| failed | Prefill errored; see prefillError. Deterministic errors fail immediately (a common one: the polygon currently contains more than 100,000 matching vessels; tighten the filters or split the area). Crash or stuck-run loops are retried up to 5 times before failing. |
The recommended client flow is create, poll, connect: after the 201, poll GET /notifications/advanced/{name} every couple of seconds until prefillStatus is "ready", then open the WebSocket. Connecting earlier returns 409 with the current status in the body.
skipPrefill: get the initial snapshot as events
Pass "skipPrefill": true at create time to skip the seeding step entirely: the notification goes straight to ready, and you receive a geofence_enter for every matching vessel as it next reports a position (busy areas fill in within minutes; a vessel that stays silent produces nothing until its next report). Use this when you want a picture of "everything in the area right now" and are prepared for the initial burst. It applies to creation only; polygon/filter updates always run a normal prefill.
- Changing the polygon or filters via PUT resets the notification to
pendingand wipes its matching state: event delivery pauses until the re-prefill completes. An already-open WebSocket stays connected but goes silent during this window; new connects get 409. - Reactivating a paused notification (
active: falsetotrue) also re-runs prefill, to avoid phantom events from stale state. - Vessel-list-only updates do not reset state: removed vessels are pruned and added vessels are seeded in place.
- A
vessel_listcannot be emptied via update (400). - Prefill only sees vessels that reported a position in the last hour; a vessel silent longer than that is picked up as a normal enter event once it reports again.
How advanced events differ
- Advanced events carry
notificationName(your chosen name) instead ofnotificationId. Correlate by name. - On advanced geofence events,
data.geofenceChange.geofenceisnull(the polygon is not echoed back). Standard geofence events include the bounding box. - Delivery is at-most-once: state is persisted before sending, so nothing is ever duplicated, but events can be lost rather than redelivered (a crash at the wrong moment, a webhook whose retries are exhausted or whose queue is full, a WebSocket buffer overflow, or no connected WebSocket client). Webhook retries reuse the same
event.id. - Within one poll tick, enters are emitted before exits, ETA events last; stale out-of-order exits are suppressed. This order is preserved on the WebSocket stream; webhook arrival order is not guaranteed.
- Event payloads are enriched with the vessel's static attributes (type, subtype, ENI, dimensions, 31-day speed/draught aggregates) inline, so the common case needs no follow-up REST call.
Advanced limits at a glance
| Notifications per account | 50 |
| Polygon ring | 4 to 5,000 points, single closed ring, no antimeridian crossing |
| vessel_list size | 10,000 vessels, each with a required MMSI |
| Filters | 20 groups of 20 predicates; 200 values per in |
| Vessels in polygon at prefill | 100,000 (beyond that prefill fails; tighten filters or split the area) |
| hysteresisMeters | 0 to 10,000, default 50 |
| etaShiftThresholdMinutes | 1 to 1,440, default 120 |
| Request body size | 1 MB |
API Endpoints
Standard notifications
/notifications
Create a new notification configuration. Returns 201 with {"notification": {...}}.
/notifications
List all notification configurations for your account.
/notifications/{id}
Retrieve a single notification configuration by ID.
/notifications/{id}
Update an existing notification. Only provided fields are changed; use active to pause/resume.
/notifications/{id}
Delete a notification configuration (204; 404 if it does not exist).
/notifications/{id}/test
Send a test event through all configured channels. Returns per-channel results (channel, success, statusCode, durationMs) plus the exact payload that was sent.
/ws?notification_id={id}
WebSocket event stream for one standard notification.
Advanced notifications (Pro)
/notifications/advanced
Create an advanced notification. 403 without the Pro plan; 409 if the name is taken.
/notifications/advanced
List your advanced notifications.
/notifications/advanced/{name}
Retrieve one advanced notification by name; poll this for prefillStatus.
/notifications/advanced/{name}
Partial update. name and mode are immutable; polygon/filter changes re-run prefill.
/notifications/advanced/{name}
Delete an advanced notification and its matching state (204).
/notifications/advanced/{name}/test
Send a test event (notification must be active; 409 otherwise).
/ws/advanced?name={name}
WebSocket event stream for one advanced notification. 403 without the Pro plan; 409 while inactive or until prefillStatus is ready; 400 if websocket delivery is not enabled.
Authentication: All endpoints require a Bearer token in the Authorization header, the same API key used across the VesselAPI. The base URL is https://api.vesselapi.com/v1. The machine-readable spec is at /v1/notifications/swagger.json, and all endpoints can be exercised in the API Explorer.
Event Payload
Every event is delivered as a JSON envelope: {"object": "event", "event": {...}}. The event carries the type, two timestamps, an enriched vessel block, and a typed data block holding exactly one domain object plus one change descriptor. Here is an advanced position.geofence_enter event:
{
"object": "event",
"event": {
"id": "94ff8b0d-08d8-466c-b37a-c024117b8880",
"type": "position.geofence_enter",
"timestamp": "2026-06-10T08:14:32Z",
"detectedAt": "2026-06-10T08:15:40Z",
"vessel": {
"mmsi": 244660123,
"imo": 9462067,
"vesselName": "MS EXAMPLE",
"nameAIS": "EXAMPLE",
"eni": "02327456",
"vesselType": "Cargo",
"vesselSubtype": "Inland cargo",
"country": "Netherlands",
"countryCode": "NL",
"length": 110,
"breadth": 11,
"heading": 215.0,
"speed": 8.4,
"lastAISUpdate": "2026-06-10T08:14:32Z",
"speedCalculatedAvg": 7.9,
"speedObservedMax": 12.1,
"draughtCalculatedAvg": 2.8,
"draughtObservedMax": 3.2
},
"data": {
"position": {
"mmsi": 244660123,
"imo": 9462067,
"vessel_name": "MS EXAMPLE",
"latitude": 51.927,
"longitude": 4.412,
"location": { "type": "Point", "coordinates": [4.412, 51.927] },
"timestamp": "2026-06-10T08:14:32Z",
"processed_timestamp": "0001-01-01T00:00:00Z",
"suspected_glitch": false,
"sog": 8.4,
"cog": 215.0,
"heading": 213,
"nav_status": 0
},
"geofenceChange": { "geofence": null }
},
"notificationName": "rotterdam_inland_watch"
}
}
data.position always includes processed_timestamp and suspected_glitch; on advanced events processed_timestamp is the zero timestamp shown above. Build your parser to tolerate zero values and unknown fields.
Envelope fields
| Field | Description |
|---|---|
| object | Always "event" (envelope discriminator) |
| event.id | Unique event UUID; identical across webhook retries (idempotency key, also sent as X-Delivery-ID) |
| event.type | One of the event type strings listed above, or test |
| event.timestamp | When the underlying AIS message / port event was reported (ISO 8601 UTC) |
| event.detectedAt | When VesselAPI detected the change; the gap to timestamp is the pipeline latency |
| event.vessel | Enriched vessel block (camelCase, see below); most fields are omitted when unknown, but mmsi and vesselName are always present (the name may be empty) |
| event.data | Exactly one domain object (position, vesselEta, or portEvent; snake_case, shared with the REST API) plus one change descriptor (see below) |
| event.notificationId | Standard notifications only: the UUID of the matching configuration |
| event.notificationName | Advanced notifications only: the name of the matching configuration. Exactly one of notificationId / notificationName is present. |
The vessel block
event.vessel is designed to make events self-contained: identity, static attributes, and the latest dynamics ride along, so most consumers never need a follow-up lookup. Most fields are omitted when unknown; mmsi and vesselName are always present (the name may be empty).
| Group | Fields |
|---|---|
| Identity | mmsi, imo, eni, vesselName (registered name), nameAIS (name broadcast on AIS) |
| Classification | vesselType, vesselSubtype, country, countryCode |
| Dimensions | length, breadth, teu, summerDraught |
| 31-day aggregates | speedCalculatedAvg, speedObservedMax, draughtCalculatedAvg, draughtObservedMax |
| Dynamics (position events) | heading, speed, lastAISUpdate. Note: heading carries the course over ground (COG) and speed the speed over ground (SOG) of the triggering AIS report; lastAISUpdate is its timestamp. The true heading is in data.position.heading. |
| Voyage (ETA events) | destination |
For the deeper static record (call sign, tonnage, ownership, build year, home port), call GET /v1/vessel/{id}. Note the casing difference: the vessel block is camelCase, while data.position / data.vesselEta / data.portEvent reuse the snake_case contract objects of the REST API.
Change descriptors per event type
| Event type | data contains |
|---|---|
| position.update / position.position_changed | position + empty positionUpdate / positionChange marker |
| position.nav_status_changed | position + navStatusChange: {previous, current} |
| position.speed_changed | position + speedChange: {previousSog, currentSog, stopped} |
| position.geofence_enter / _exit | position + geofenceChange: {geofence} (the bounding box on standard; null on advanced) |
| eta.destination_changed | vesselEta + destinationChange: {previous, current} |
| eta.eta_changed | vesselEta + etaChange: {previousEta, currentEta, shiftMinutes} |
| eta.draught_changed | vesselEta + draughtChange: {previous, current} |
| port.arrival / port.departure | portEvent (with port: {name, country, unlo_code}) + empty portCall marker |
| test | message string only |
ETA events carry no position object; if you place events on a map, fall back to a known location for those.
Code Examples
SDK note: none of the official SDKs cover the Notifications API yet; support is coming. Until then, use plain HTTP as shown below: the surface is small and needs no client library.
Create a Standard Notification (cURL)
curl -X POST "https://api.vesselapi.com/v1/notifications" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Ever Given Port Alerts",
"imos": [9811000],
"eventTypes": ["port.arrival", "port.departure"],
"webhookUrl": "https://your-app.com/webhooks/vessel-events",
"webhookSecret": "whsec_your_secret_key"
}'
Advanced Geofence over WebSocket (Java)
The full lifecycle on the JVM with only java.net.http: create the notification, poll until prefill is ready, stream events with reconnect. (The JDK WebSocket answers server pings automatically.)
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.concurrent.*;
public class GeofenceListener {
static final String BASE = "https://api.vesselapi.com/v1";
static final String WS_BASE = "wss://api.vesselapi.com/v1";
static final String KEY = System.getenv("VESSELAPI_API_KEY");
static final String NAME = "rotterdam_inland_watch";
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build();
public static void main(String[] args) throws Exception {
String body = """
{
"name": "%s",
"mode": "any_vessel",
"polygon": { "coordinates": [
[4.00, 51.85], [4.60, 51.85], [4.60, 52.05], [4.00, 52.05], [4.00, 51.85]
]},
"filterGroups": [ { "predicates": [ { "field": "eni", "op": "present" } ] } ],
"hysteresisMeters": 500,
"websocket": true,
"eventTypes": ["position.geofence_enter", "position.geofence_exit"]
}""".formatted(NAME);
HttpResponse<String> created = HTTP.send(request("/notifications/advanced")
.POST(HttpRequest.BodyPublishers.ofString(body))
.header("Content-Type", "application/json").build(),
HttpResponse.BodyHandlers.ofString());
// Re-running with the same name returns 409: delete the old
// notification first or pick a fresh name.
if (created.statusCode() != 201) throw new IllegalStateException(created.body());
// Poll until prefill completes; connecting earlier returns 409.
while (true) {
String notif = HTTP.send(request("/notifications/advanced/" + NAME).GET().build(),
HttpResponse.BodyHandlers.ofString()).body();
// Response shape: {"notification": {..., "prefillStatus": "ready"}}
if (notif.contains("\"prefillStatus\":\"ready\"")) break;
if (notif.contains("\"prefillStatus\":\"failed\"")) throw new IllegalStateException(notif);
Thread.sleep(2000);
}
connect(1);
new CountDownLatch(1).await(); // keep the process alive for the stream
}
static void connect(long backoffSeconds) {
try {
HTTP.newWebSocketBuilder()
.header("Authorization", "Bearer " + KEY)
.buildAsync(URI.create(WS_BASE + "/ws/advanced?name=" + NAME), new WebSocket.Listener() {
final StringBuilder buf = new StringBuilder();
public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
buf.append(data);
if (last) {
System.out.println("event: " + buf); // {"object":"event","event":{...}}
buf.setLength(0);
}
ws.request(1);
return null;
}
public CompletionStage<?> onClose(WebSocket ws, int status, String reason) {
// 1012 = server restart: reconnect immediately, otherwise back off.
retry(status == 1012 ? 1 : Math.min(backoffSeconds * 2, 30));
return null;
}
public void onError(WebSocket ws, Throwable error) {
// Abnormal disconnects (network drop, keepalive kill) land
// here instead of onClose: reconnect with backoff too.
retry(Math.min(backoffSeconds * 2, 30));
}
}).join();
} catch (Exception e) {
retry(Math.min(backoffSeconds * 2, 30)); // failed (re)connect attempt: keep trying
}
}
static void retry(long delaySeconds) {
CompletableFuture.delayedExecutor(delaySeconds, TimeUnit.SECONDS)
.execute(() -> connect(delaySeconds));
}
static HttpRequest.Builder request(String path) {
return HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + KEY);
}
}
Create a Notification (JavaScript, plain fetch)
const res = await fetch("https://api.vesselapi.com/v1/notifications", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Ever Given Port Alerts",
imos: [9811000],
eventTypes: ["port.arrival", "port.departure"],
webhookUrl: "https://your-app.com/webhooks/vessel-events",
webhookSecret: "whsec_your_secret_key",
}),
});
const { notification } = await res.json(); // note the envelope
console.log(`Created notification: ${notification.id}`);
Handle Incoming Webhook (Python / Flask)
import hmac, hashlib, json
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret_key"
@app.route("/webhooks/vessel-events", methods=["POST"])
def handle_event():
# Verify signature
signature = request.headers.get("X-Signature-256", "")
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), request.data, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
envelope = request.json
event = envelope["event"]
event_type = event["type"]
if event_type == "port.arrival":
port = event["data"]["portEvent"]["port"]["name"]
vessel = event["vessel"]["vesselName"]
print(f"{vessel} arrived at {port}")
return "", 200
Try It in the API Explorer
For full endpoint details, request/response schemas, and live testing, open the interactive API Explorer.