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.
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.
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}
]
}
| Field | Required | Notes |
|---|---|---|
lat, lon | yes | Decimal degrees, WGS84. latitude/longitude/lng also accepted. |
time | no | ISO-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_mps | no | speed_kmh and speed_knots are converted for you. |
heading | no | Degrees clockwise from true north. course and bearing also accepted. |
alt_m | no | Metres above the ellipsoid. |
accuracy_m | no | Fixes worse than 500 m are stored but do not move the odometer. |
hdop, satellites, battery_pct | no | Recorded as reported. |
{
"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.
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.
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.
| Endpoint | Auth | What it is for |
|---|---|---|
GET /api/v1/device | Device key | What the server already holds for this device, so a tracker can decide on power-up whether its backlog is needed. |
GET /api/v1/time | Open | UTC and GPS time. A receiver with a rough time fixes far faster than one with none. |
GET /api/convert?q= | Open | Any coordinate format in, every format out: decimal, DMS, UTM, MGRS, geohash, Plus Code. |
GET /api/distance?from=&to= | Open | Vincenty distance and bearing on WGS84. |
GET /api/sky?lat=&lon= | Open | What is overhead now or at a given time, with the expected dilution of precision. |
GET /api/forecast?lat=&lon= | Open | Satellite geometry over the coming hours, and the best window in it. |
GET /api/status | Open | Health, 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.
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.