Developer Documentation API v1

Build with Dhuveli

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.

REST · JSON Bearer-token auth 60 req / min Event webhooks Embeddable map

Authentication

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
Keep tokens secret — call the API from your server, not from browser JavaScript, so the token isn't exposed. A token carries only the abilities you grant it, and can be revoked at any time.

Abilities

Choose a token's abilities when you create it under Settings → API Access:

AbilityGrants
readRead vessels, tracks and share links. Always granted.
manage-linksCreate, update and delete share links.
manage-webhooksCreate, update and delete webhook endpoints.

A request that needs an ability the token lacks returns 403 Forbidden.

Base URL

https://dhuveli.com/api/v1

Rate limit

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.

Endpoints

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
    }
  ]
}

Share links

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 fieldDescription
vessel_idsArray of vessel ids to show on the link (must belong to you and have a tracker).
expires_atRequired 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 fieldDescription
expires_atNew ISO 8601 expiry, in the future (links stay temporary — you can extend but not remove the expiry).
activeBoolean — enable or disable the link without deleting it.
vessel_idsArray 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 }

Embedding the live map

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

Live data feed (optional)

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 reference

FieldMeaning
idStable vessel identifier (use this to track a vessel across calls).
state.keymoving · idle · stopped · unknown
position.speedSpeed over ground, in knots and kmh.
position.courseHeading in degrees (0–359) and a 16-point cardinal label.
position.powerBattery/supply voltage. on is true above 6 V; hasData false if the device reports none.
fixTimeWhen the device recorded the fix (ISO 8601, UTC).

Webhooks

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.

Events

TypeFired when
vessel.channel_crossedA vessel crosses a channel. Carries channel.name and a compass heading.
vessel.geofence_enterA vessel enters a named place or ETA zone (arrival). Carries geofence.name and geofence.type.
vessel.geofence_exitA vessel leaves a named place.
vessel.signal_lostThe 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_restoredThe tracker is reporting again. Carries since and silent_minutes for the outage that just ended.
vessel.movingThe 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.stoppedThe 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_completedA 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.arrivingA 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.overspeedSpeed threshold exceeded. Carries speed_knots.
vessel.power_cutThe tracker lost external power.
vessel.power_restoredExternal power came back.
vessel.alarmAny other device alarm (e.g. SOS). Carries the raw alarm code.

Payload

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" }
}

Headers & signature verification

HeaderValue
X-Dhuveli-EventThe event type.
X-Dhuveli-DeliveryThe delivery/event id (matches id in the body).
X-Dhuveli-TimestampUnix seconds, part of the signed base.
X-Dhuveli-Signaturesha256=<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));
}

Delivery, retries & security

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.

Managing endpoints via the API

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.

Errors

StatusMeaning
401Missing or invalid token.
403Token lacks the required ability (e.g. manage-links).
404Vessel or link not found in your company.
422Invalid query parameters.
429Rate limit exceeded.