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