Developer Documentation API v1
Integrate live vessel tracking into your own apps — a simple HTTP API for reading fleet data, managing share links and receiving event webhooks, plus an embeddable live map.
The API uses bearer tokens. Generate one in Dhuveli under Settings → API Access. The token is shown once — copy it then. Every request is scoped to your company; you only ever see your own vessels.
Send the token in the Authorization header on every request:
Authorization: Bearer YOUR_TOKEN_HERE
Accept: application/json
Choose a token's abilities when you create it under Settings → API Access:
| Ability | Grants |
|---|---|
read | Read vessels, tracks and share links. Always granted. |
manage-links | Create, update and delete share links. |
manage-webhooks | Create, update and delete webhook endpoints. |
A request that needs an ability the token lacks returns 403 Forbidden.
https://dhuveli.com/api/v1
60 requests per minute per token. Over the limit returns 429 Too Many Requests. For a live map, poll no faster than every ~10 seconds.
GET/vessels
All your active vessels, each with its latest position.
curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json" \
https://dhuveli.com/api/v1/vessels
Response
{
"data": [
{
"id": 12,
"name": "Ocean Queen",
"type": "Dhoani",
"capacity": 40,
"state": { "key": "moving", "label": "Moving" },
"position": {
"lat": 3.947643,
"lng": 73.486898,
"fixTime": "2026-06-17T16:34:18+00:00",
"speed": { "knots": 9.2, "kmh": 17.0 },
"course": { "degrees": 163, "cardinal": "SSE" },
"power": { "volts": 12.2, "on": true, "hasData": true }
}
}
]
}
position is null and state.key is "unknown" when a vessel has no recorded fixes.
GET/vessels/{id}/track
The most recent activity for one vessel, newest first: up to the last 30 positions and the last 10 events (each with its time). No query parameters.
curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json" \
"https://dhuveli.com/api/v1/vessels/12/track"
Response
{
"vessel": { "id": 12, "name": "Ocean Queen" },
"positions": [
{
"lat": 3.947643,
"lng": 73.486898,
"speed": { "knots": 9.2, "kmh": 17.0 },
"course": { "degrees": 163, "cardinal": "SSE" },
"fixTime": "2026-06-17T16:34:18+00:00"
}
],
"events": [
{
"type": "deviceMoving",
"time": "2026-06-17T16:30:02+00:00",
"attributes": null
}
]
}
Control which vessels appear on a public tracking link (/l/<code>).
Reading links works with any token; creating a temporary link or
changing a link's vessels requires a token created with the
“Allow managing share links” option.
GET/links
Your share links and the vessels currently on each.
{
"data": [
{
"code": "sunset-ferry",
"url": "https://dhuveli.com/l/sunset-ferry",
"active": true,
"expires_at": null,
"vessels": [ { "id": 12, "name": "Ocean Queen" } ]
}
]
}
POST/links
Create a temporary share link. An expiry is required — links
created through the API always expire. Requires the manage-links ability.
| Body field | Description |
|---|---|
vessel_ids | Array of vessel ids to show on the link (must belong to you and have a tracker). |
expires_at | Required ISO 8601 datetime in the future — when the link stops working. |
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"vessel_ids": [12, 15], "expires_at": "2026-06-18T17:00:00Z"}' \
https://dhuveli.com/api/v1/links
Response (201 Created)
{
"code": "a1b2c3d4",
"url": "https://dhuveli.com/l/a1b2c3d4",
"active": true,
"expires_at": "2026-06-18T17:00:00+00:00",
"vessels": [
{ "id": 12, "name": "Ocean Queen" },
{ "id": 15, "name": "Reef Runner" }
]
}
PUT/links/{code}/vessels
Replace which vessels are shown on a link. {code} is the link's slug (the part after /l/). Requires the manage-links ability.
curl -X PUT \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"vessel_ids": [12, 15]}' \
https://dhuveli.com/api/v1/links/sunset-ferry/vessels
Response
{
"code": "sunset-ferry",
"url": "https://dhuveli.com/l/sunset-ferry",
"active": true,
"expires_at": "2026-07-01T00:00:00+00:00",
"vessels": [
{ "id": 12, "name": "Ocean Queen" },
{ "id": 15, "name": "Reef Runner" }
]
}
Create the link itself (and its {code}) once in the dashboard under Shared Links; this endpoint then drives which vessels it shows.
PATCH/links/{code}
Update an existing link. Send any of the fields below (at least one required). Requires the manage-links ability, and you can only update your own links.
| Body field | Description |
|---|---|
expires_at | New ISO 8601 expiry, in the future (links stay temporary — you can extend but not remove the expiry). |
active | Boolean — enable or disable the link without deleting it. |
vessel_ids | Array of vessel ids to show (must belong to you and have a tracker). |
curl -X PATCH \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"expires_at": "2026-07-01T00:00:00Z", "active": false}' \
https://dhuveli.com/api/v1/links/sunset-ferry
Response — the updated link (same shape as POST /links).
DELETE/links/{code}
Permanently delete one of your links — the public /l/{code} page stops working immediately. Requires the manage-links ability, and you can only delete your own links.
curl -X DELETE \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json" \
https://dhuveli.com/api/v1/links/sunset-ferry
Response
{ "code": "sunset-ferry", "deleted": true }
You can embed a share link's live map directly in your own site with an
<iframe>. For security, a link only loads in a frame on
domains you've whitelisted under Settings → API Access → iFrame
embedding — every other site is refused by the browser.
<iframe
src="https://dhuveli.com/l/sunset-ferry"
width="100%" height="500" style="border:0"
allowfullscreen></iframe>
src must be https, and its domain must be on the
link's company whitelist. A link with no vessels, or an expired one, shows a
friendly status page inside the frame rather than failing.
Add ?t=iframe to the URL to turn on a postMessage feed:
clicking a vessel no longer opens the in-map popup — instead the vessel's details
are sent to your page, and the selected vessel keeps streaming updates on every refresh.
This lets you render the data in your own UI.
<iframe src="https://dhuveli.com/l/sunset-ferry?t=iframe" …></iframe>
<script>
window.addEventListener("message", (event) => {
// 1. Only trust messages from Dhuveli
if (event.origin !== "https://dhuveli.com") return;
if (event.data?.source !== "dhuveli") return;
if (event.data.type === "vessel.selected") {
// user tapped a vessel
showVessel(event.data.vessel);
} else if (event.data.type === "vessel.update") {
// same vessel, fresh data (~every 10s)
updateVessel(event.data.vessel);
}
});
</script>
Message shape (both vessel.selected and vessel.update):
{
"source": "dhuveli",
"type": "vessel.selected",
"version": 1,
"timestamp": "2026-06-17T11:34:20.512Z",
"vessel": {
"name": "Ocean Queen",
"descriptor": "Dhoani",
"status": "online",
"state": { "key": "moving", "label": "Moving" },
"position": { "lat": 3.947643, "lng": 73.486898, "fixTime": "…", "fixAgeLabel": "Just now" },
"speed": { "knots": 9.2, "kmh": 17.0 },
"course": { "degrees": 163, "cardinal": "SSE" },
"power": { "volts": 12.2, "on": true, "hasData": true },
"track": { "distanceKm": 1.36, "fixCount": 30 }
}
}
The vessel object uses the same fields as the API responses below
(no internal device id). Always verify event.origin before trusting a message.
| Field | Meaning |
|---|---|
id | Stable vessel identifier (use this to track a vessel across calls). |
state.key | moving · idle · stopped · unknown |
position.speed | Speed over ground, in knots and kmh. |
position.course | Heading in degrees (0–359) and a 16-point cardinal label. |
position.power | Battery/supply voltage. on is true above 6 V; hasData false if the device reports none. |
fixTime | When the device recorded the fix (ISO 8601, UTC). |
Receive vessel events at your own URL as they happen — the same events we post to Telegram.
Add an endpoint under Settings → API Access → Event webhooks, or manage them
with the API below (token needs the manage-webhooks ability). Each endpoint gets a
signing secret, shown once, used to verify every delivery.
| Type | Fired when |
|---|---|
vessel.channel_crossed | A vessel crosses a channel. Carries channel.name and a compass heading. |
vessel.geofence_enter | A vessel enters a named place or ETA zone (arrival). Carries geofence.name and geofence.type. |
vessel.geofence_exit | A vessel leaves a named place. |
vessel.signal_lost | The vessel's tracker has been silent for two days — powered off, out of coverage or faulty. Sent once per outage; trackers sleep for hours when a boat is docked, so shorter silences are normal and not reported. Carries since (last heard, ISO-8601) and silent_minutes. |
vessel.signal_restored | The tracker is reporting again. Carries since and silent_minutes for the outage that just ended. |
vessel.moving | The vessel got underway — the start of a leg. Carries place (the island, harbour or zone it left, when known). Frequent: roughly one per leg. |
vessel.stopped | The vessel came to a stop — the end of a leg. Carries place. Together with vessel.moving and the geofence events, these are the edges of a trip. |
vessel.trip_completed | A voyage ended — one summary per trip from Dhuveli's trip log, sent a few minutes after arrival once the stop is confirmed. Carries trip_id, from and to (name, lat, lng; name null at sea), started_at, ended_at, duration_minutes, moving_minutes, distance_nm, avg_speed_knots, cruise_speed_knots, max_speed_knots, speed_limit_knots and over_limit_minutes. |
vessel.arriving | A heads-up about ten minutes before a vessel reaches where it is evidently going — judged from where it usually goes from the place it left and the route it is on. Once per voyage, only when the guess is confident, never for hops under five minutes. Carries place (name, key, lat, lng), eta_at, minutes, confidence (0–1), method (history or course) and from. |
vessel.overspeed | Speed threshold exceeded. Carries speed_knots. |
vessel.power_cut | The tracker lost external power. |
vessel.power_restored | External power came back. |
vessel.alarm | Any other device alarm (e.g. SOS). Carries the raw alarm code. |
Every delivery is a JSON POST. Vessels are identified by id only. The
source block is our stamp. id is stable across retries — use it to dedupe.
{
"id": "evt_01j9x8y7z6...",
"type": "vessel.channel_crossed",
"occurred_at": "2026-07-25T08:14:22+00:00",
"data": {
"vessel": { "id": 42, "name": "Ocean Queen" },
"event": {
"channel": { "name": "North Channel" },
"heading": "NE"
}
},
"source": { "provider": "Dhuveli", "api_version": "v1" }
}
| Header | Value |
|---|---|
X-Dhuveli-Event | The event type. |
X-Dhuveli-Delivery | The delivery/event id (matches id in the body). |
X-Dhuveli-Timestamp | Unix seconds, part of the signed base. |
X-Dhuveli-Signature | sha256=<hex> — HMAC-SHA256 of "{timestamp}.{rawBody}" with your endpoint secret. |
Verify against the raw request body (before JSON parsing), using a constant-time compare:
// Node.js (Express, express.raw())
const crypto = require('crypto');
function verify(req, secret) {
const ts = req.get('X-Dhuveli-Timestamp');
const sig = req.get('X-Dhuveli-Signature');
const base = ts + '.' + req.body; // req.body is the raw Buffer/string
const mine = 'sha256=' + crypto.createHmac('sha256', secret).update(base).digest('hex');
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(mine));
}
Respond 2xx to acknowledge. Non-2xx or a timeout is retried with exponential backoff
(10s → 30s → 1m → 5m → 15m → 1h). After repeated consecutive failures an endpoint is automatically
disabled — re-enable it in Settings. Callback URLs must be public https; URLs that
resolve to private, loopback or link-local addresses are rejected. Redirects are not followed.
GET/webhooks
List your endpoints (secrets are never returned). Requires manage-webhooks.
POST/webhooks
Register an endpoint. The signing secret is returned once. Omit
event_types (or send an empty array) to receive every event.
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"url": "https://your-app.com/webhooks/dhuveli", "description": "Production", "event_types": ["vessel.geofence_enter", "vessel.overspeed"]}' \
https://dhuveli.com/api/v1/webhooks
Response (201 Created)
{
"id": 3,
"url": "https://your-app.com/webhooks/dhuveli",
"description": "Production",
"event_types": [ "vessel.geofence_enter", "vessel.overspeed" ],
"active": true,
"secret": "whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
PATCH/webhooks/{id} · DELETE/webhooks/{id}
Update (url, description, event_types, active) or delete an endpoint. Setting active: true also clears the auto-disable.
POST/webhooks/{id}/test
Queue a webhook.test ping to the endpoint so you can confirm signature verification end-to-end.
| Status | Meaning |
|---|---|
401 | Missing or invalid token. |
403 | Token lacks the required ability (e.g. manage-links). |
404 | Vessel or link not found in your company. |
422 | Invalid query parameters. |
429 | Rate limit exceeded. |