Putting a device on DENGPS

Two calls do almost everything. One delivers positions, one fetches satellite assistance. Both are plain JSON over HTTPS, authenticated with a key you issue per device in the console.

The base URL is https://gps.dendat.ai. Assistance covers GPS and Galileo, from the US Coast Guard Navigation Center and the European GNSS Service Centre respectively. Both publish free and without an API key, which is what makes it possible to run this platform on your own hardware.

Authentication

Every device gets its own key, shown once when it is issued and stored here only as a hash. If a key is lost, reissue it; the old one stops working immediately. Send it in either form:

X-Device-Key: dgp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
or
Authorization: Bearer dgp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A bad or missing key returns 401 with a JSON body. It never returns a redirect to a login page, because a tracker would read a 302 as success and delete the backlog it was holding.

Delivering positions

POST /api/v1/positions

Send one position:

curl -X POST https://gps.dendat.ai/api/v1/positions \
  -H "X-Device-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"lat": 5.6037, "lon": -0.1870, "speed_kmh": 48, "heading": 217}'

Or a backlog, which is what a device does after time out of coverage:

{
  "positions": [
    {"time": "2026-09-01T06:14:02Z", "lat": 5.6037, "lon": -0.1870, "speed_mps": 13.4},
    {"time": "2026-09-01T06:14:32Z", "lat": 5.6041, "lon": -0.1878, "speed_mps": 12.9}
  ]
}

Fields

FieldRequiredNotes
lat, lonyesDecimal degrees, WGS84. latitude/longitude/lng also accepted.
timenoISO-8601 with or without an offset, or a Unix epoch in seconds or milliseconds. Missing means now. A time more than an hour in the future is rejected, because a device clock that far out would pin the fleet's "last seen" column to a date that never arrives.
speed_mpsnospeed_kmh and speed_knots are converted for you.
headingnoDegrees clockwise from true north. course and bearing also accepted.
alt_mnoMetres above the ellipsoid.
accuracy_mnoFixes worse than 500 m are stored but do not move the odometer.
hdop, satellites, battery_pctnoRecorded as reported.

What comes back

{
  "ok": true,
  "accepted": 138,
  "duplicate": 412,
  "rejected": [],
  "events": [{"geofence_id": 3, "kind": "exit", "at": "2026-09-01T06:41:10Z"}],
  "server_time": "2026-09-01T09:02:44Z"
}

Delete your local backlog when accepted + duplicate + rejected covers what you sent. Duplicates mean the server already holds those positions, which is the normal outcome of retrying after a dropped connection. Every position is fingerprinted by device, second and coordinates with a uniqueness constraint behind it, so resending is always safe and never doubles a distance total.

server_time is returned on every reply so a device whose clock drifted while it was powered off can correct itself without a second call. A batch is limited to 2 000 positions and is applied as one transaction: a half-applied backlog is worse than a rejected one, because the device believes it delivered everything and the gap is invisible until somebody audits a route months later.

Satellite assistance

GET /api/v1/assist?lat=5.6&lon=-0.19

Give a rough position. A hundred kilometres of error changes almost nothing, so the last known fix, a cell tower, or the depot the vehicle left from are all good enough. The reply covers both GPS and Galileo; each satellite carries an sv label (G14, E22) and a constellation, because a bare PRN cannot say which system it belongs to.

{
  "issued_at": "2026-09-01T09:02:44Z",
  "valid_until": "2026-09-01T10:02:44Z",
  "gps_time": {"week": 2434, "time_of_week": 205364.0, "leap_seconds": 18},
  "constellations": ["GAL", "GPS"],
  "visible": [
    {"sv": "G08", "constellation": "GPS", "elevation_deg": 72.0, "azimuth_deg": 254.1, "doppler_l1_hz": -838.8, ...},
    {"sv": "E05", "constellation": "GAL", "elevation_deg": 50.2, "azimuth_deg": 141.4, "doppler_l1_hz": 1971.1, ...}
  ],
  "dop": {"pdop": 0.98, "hdop": 0.52, "vdop": 0.83, "systems": ["GPS", "GAL"]},
  "almanac": {"age_days": 1.4, "by_constellation": {"GPS": {...}, "GAL": {...}}},
  "elements": [ ... orbital elements for every satellite ... ]
}

The pack carries both the predictions and the orbital elements they were computed from. Keep the elements and the device can regenerate its own predictions for a new day and a new place with no link at all. Pass elements=0 to leave them out on a very thin link, and mask= to change the elevation cut-off from the default of five degrees.

valid_until is an hour out. That is the shelf life of the predictions: the satellite list barely changes in an hour, but the Doppler figures do, and a receiver reusing an old pack searches the wrong bins. The almanac inside stays good for weeks.

dop.systems lists the constellations in the fix, and the DOP figures account for one unknown per system: GPS and Galileo keep their own system time, so a mixed fix solves for position, the receiver clock, and the offset between the two. A receiver that can only track GPS should ask for constellation=GPS rather than be handed Galileo search bins it will waste time on.

Carrying assistance to a site with no link

GET /api/v1/almanac.alm

Returns the stored almanac verbatim: YUMA for GPS, the plain-text form every receiver vendor understands, and the Service Centre's XML for Galileo with ?constellation=GAL. Byte-for-byte what the provider published, so a receiver's own parser will take it. Fetch it where you have a connection, carry it on a memory stick, and load it into the installation that does not. That is not a hypothetical; it is how assistance reaches a site behind a satellite phone.

Other endpoints

EndpointAuthWhat it is for
GET /api/v1/deviceDevice keyWhat the server already holds for this device, so a tracker can decide on power-up whether its backlog is needed.
GET /api/v1/timeOpenUTC and GPS time. A receiver with a rough time fixes far faster than one with none.
GET /api/convert?q=OpenAny coordinate format in, every format out: decimal, DMS, UTM, MGRS, geohash, Plus Code.
GET /api/distance?from=&to=OpenVincenty distance and bearing on WGS84.
GET /api/sky?lat=&lon=OpenWhat is overhead now or at a given time, with the expected dilution of precision.
GET /api/forecast?lat=&lon=OpenSatellite geometry over the coming hours, and the best window in it.
GET /api/statusOpenHealth, plus whether assistance is available and how old it is.

The tools are open because they are arithmetic over the query string, and the constellation data is published by a government for exactly this purpose. Anything that reads a customer's devices or positions needs a session or a device key, always.

A minimal tracker

This is the whole contract. Queue locally, send in batches, delete what the server confirms, and never delete what it did not.

# queue.jsonl holds one position per line while there is no link
import json, urllib.request

KEY  = "dgp_..."
BASE = "https://gps.dendat.ai"

def deliver(positions):
    body = json.dumps({"positions": positions}).encode()
    request = urllib.request.Request(
        BASE + "/api/v1/positions", data=body, method="POST",
        headers={"X-Device-Key": KEY, "Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=30) as response:
        result = json.load(response)
    # Settled means the server has them, or will never take them.
    settled = result["accepted"] + result["duplicate"] + len(result["rejected"])
    return settled >= len(positions)

Standard library only, no dependencies, so it runs unchanged on a small board in a vehicle.