GPStrix

The GPStrix API and webhooks.

Read your fleet into your own systems over HTTPS, and have events posted to you as they happen. Version 1 of the API is read-only. Everything on this page is taken from the code that serves it.

Base URL
https://gpstrix.hashtrix.in/api/v1
Auth
Bearer API key
Format
JSON, UTF-8
Schema
OpenAPI at /openapi.json

API keys.

An owner or admin creates keys in the console, under Integrations. A key looks like gpx_live_3f9a1c0e_…. The eight characters after gpx_live_ are its prefix. The console shows the prefix next to each key, so a key found in a log or a repository can be matched to the one you revoke.

The full key is shown once, when you create it. We keep only a SHA-256 hash, so we cannot show it to you again. Lose it and you make a new one.

Scope
fleet:read. It is the only scope in v1.
Reach
Either the whole account, or only the vehicle groups you pick. A record outside a key's groups answers 404, exactly as a record that does not exist.
Expiry
Optional. After the date the key is refused.
Rate limit
Optional, per key, in requests per minute. Useful to fence in one integration so it cannot use up the account's budget.
Revoking
Takes effect on the next request.

A key belongs to the account, so it follows the account's state. If the account is suspended for non-payment, its keys stop working with it. API access is part of the plan: on a plan without it, every call answers 402 with the code api.public.

Your first request.

Send the key as a bearer token. Start with /whoami: it answers even for a key with no scopes, and tells you which account the key belongs to, how far it reaches and what the plan includes. When something else is refused, this is the call that tells you why.

Shellcurl
export GPSTRIX_KEY="gpx_live_…"

curl https://gpstrix.hashtrix.in/api/v1/whoami \
  -H "Authorization: Bearer $GPSTRIX_KEY"
200 OKExample. Your ids and plan differ.
{
  "tenant_id": "6f1c2b0e-8a1d-4b7e-9f0a-2d4c6e8a1b3c",
  "role": "api_client",
  "key_id": "0b6e7a52-3c9d-4f1e-8a27-5d9c1e0f4a68",
  "scopes": ["fleet:read"],
  "device_scope": "tenant",
  "entitlements": {
    "api.public": true,
    "api.webhooks": true,
    "limit.history_days": 180,
    …
  }
}

Resources.

Every route is a GET. Paths below are relative to the base URL.

The v1 routes
PathReturnsQuery
/vehiclesVehicles: id, registration, make, model, vehicle_group_id, archived_atinclude_archived
/vehicles/{id}One vehicle
/devices, /devices/{id}Trackers: unique_id (usually the IMEI), model, protocol, lifecycle_state, vehicle_id
/drivers, /drivers/{id}Drivers: name, licence_number, phone, tag_id, archived_atinclude_archived
/vehicle-groupsGroups: id, name
/geofences, /geofences/{id}Geofences, with area as a CIRCLE, POLYGON or LINESTRING string
/positionsPosition history, oldest firstsince, until, vehicle_id
/positions/latestThe newest fix for each vehicle, from the last 24 hours
/tripsTrips with distance, duration, idle time, top and average speed, start and end pointssince, until, vehicle_id
/eventsAlarms, geofence entries and exits, ignition and the restsince, until, vehicle_id, type
/reportsThe report catalogue and whether your plan includes each one
/reports/runs/{id}One report run: status, row count, and a download URL when finished
/openapi.jsonThe OpenAPI document for v1, built from the live routes
Time windows
since and until are ISO 8601 instants, for example 2026-10-06T00:00:00Z. Leave them out and you get the last 24 hours. Asking further back than your plan keeps history answers 402 with the code limit.history_days.
Units
speed_kmh is km/h; trackers report knots and we convert. heading is null when the tracker sent none, because 0 means due north.
Event types
Spelled as the tracking gateway (Traccar) spells them: geofenceEnter, ignitionOn, alarm. There is no sos type: an SOS arrives as alarm with "alarm": "sos" in attributes. An unknown type answers 422 and lists the valid ones.

Paging with a cursor.

Every list answers in the same shape: items, total, limit and next_cursor. Ask for up to 200 items with limit (the default is 50). While next_cursor is not null, pass it back as cursor to get the next page.

Pages are cursors rather than page numbers because positions arrive late. A truck that drove through a dead zone uploads its stored positions when it gets signal back, and those rows land behind the ones you have already read. Page numbers would then repeat some rows and silently skip others. A cursor does neither.

total is null on positions, trips and events, because counting them means reading all of them. A cursor that we did not issue answers 422 invalid_cursor rather than quietly starting again from the beginning.

Python 3Standard library only
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request

BASE = "https://gpstrix.hashtrix.in/api/v1"
KEY = os.environ["GPSTRIX_KEY"]


def get(path, **params):
    query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
    request = urllib.request.Request(
        f"{BASE}{path}?{query}", headers={"Authorization": f"Bearer {KEY}"}
    )
    while True:
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code != 429:
                raise
            time.sleep(int(error.headers.get("Retry-After", "60")))


cursor = None
while True:
    page = get("/positions", since="2026-10-06T00:00:00Z", limit=200, cursor=cursor)
    for fix in page["items"]:
        print(fix["recorded_at"], fix["vehicle_id"], fix["latitude"], fix["longitude"])
    cursor = page["next_cursor"]
    if cursor is None:
        break
One page of /positionsExample
{
  "items": [
    {
      "vehicle_id": "c2a4e1f0-5b3d-4c8e-9a71-0e6d2f4b8c19",
      "device_id": "9d0f3b2a-1e4c-4a6b-8f5d-7c2e9a1b3d40",
      "recorded_at": "2026-10-06T06:41:12Z",
      "latitude": 18.6298,
      "longitude": 73.8478,
      "speed_kmh": 37.04,
      "heading": 212.0,
      "ignition": true,
      "odometer_km": 48211.6
    }
  ],
  "total": null,
  "limit": 200,
  "next_cursor": "WyIyMDI2LTEwLTA2IDA2OjQxOjEyKzAwOjAwIiwgIjlkMGYzYjJhLTFlNGMtNGE2Yi04ZjVkLTdjMmU5YTFiM2Q0MCJd"
}

Rate limits and errors.

Requests are counted per minute in two buckets: one for the key, one for the whole account. Whichever is tighter decides. Unless your plan sets a different number, the account allows 120 requests a minute; a key has no limit of its own unless you give it one. The budget is sent on every response, not only when you run out, so a client can slow down before it is refused.

Rate-limit headers
HeaderMeaning
X-RateLimit-Scopekey or tenant: which bucket is the tighter one. If it says key, fix the polling loop. If it says tenant, the account needs a bigger allowance.
X-RateLimit-LimitRequests allowed in the current minute.
X-RateLimit-RemainingRequests left in it.
X-RateLimit-ResetSeconds until the window resets.
Retry-AfterOn a 429 only: seconds to wait.

Every refusal has the same body, so one parser handles all of them. Match on code, not on message: the wording may change, the codes will not.

429 Too Many RequestsThe error envelope
{
  "error": {
    "code": "limit.api_requests_per_minute",
    "message": "…",
    "details": { "scope": "tenant", "limit": 120, "retry_after": 23 }
  }
}
401
unauthenticated: no key, a wrong key, a revoked key or an expired one. We do not say which.
402
The plan does not include this. code names the feature, such as api.public or limit.history_days. Codes starting billing. mean the account is in arrears.
403
forbidden: the key does not hold the scope, or a console sign-in token was sent instead of an API key.
404
not_found, including records outside the key's reach.
422
invalid_request (a bad parameter, listed in details.errors), invalid_cursor, unknown_event_type.
429
Rate limited. Wait for Retry-After.

Versions. Inside v1, no field is removed or changes meaning; new fields may appear, so ignore ones you do not know. When a v2 ships, v1 keeps working for 12 months, and during that time every v1 response carries Deprecation and Sunset headers with the dates.

Webhooks: events posted to your server.

In the console, under Integrations, add an HTTPS endpoint and choose the event types it should receive. When one of those events happens on any vehicle in the account, we POST it to your URL. The endpoint gets a signing secret, shown once, which you use to check that a request really came from us.

These are the event types you can subscribe to. They are the gateway's own names:

  • alarm
  • commandResult
  • deviceFuelDrop
  • deviceFuelIncrease
  • deviceInactive
  • deviceMoving
  • deviceOffline
  • deviceOnline
  • deviceOverspeed
  • deviceStopped
  • deviceUnknown
  • driverChanged
  • geofenceEnter
  • geofenceExit
  • ignitionOff
  • ignitionOn
  • maintenance
  • media
  • queuedCommandSent
  • textMessage

Harsh braking, harsh acceleration, harsh cornering and our own overspeed findings are worked out overnight from the day's positions, not sent as they happen, so they cannot be webhooks. Read them from /events instead. Saving a subscription that names one of them is refused with a message saying exactly that.

What arrivesHeaders and body of one delivery
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: GPStrix-Webhooks/1
X-GPStrix-Delivery: b73e1c40-a6f0-4b85-acce-7e2898a01b0a
X-GPStrix-Event: geofenceEnter
X-GPStrix-Timestamp: 1791384592
X-GPStrix-Signature: v1=504e2fdbabf9f4feca30fb45db59142309be233ba7fb24c5a2bd6978d4fab441

{"created_at":"2026-10-07T09:14:03.512000+00:00","data":{"attributes":{},
"device_id":"0b130adb-d025-4a95-9df0-238e2bf189b8","driver_id":null,
"geofence_id":"5e99eca9-59a9-4464-80d5-0d8d3f119311",
"occurred_at":"2026-10-07T09:14:01+00:00","type":"geofenceEnter",
"vehicle_id":"72a7bf60-39b1-495c-8330-c31a8190414b"},"event":"geofenceEnter",
"event_id":"1759821234567-0","id":"b73e1c40-a6f0-4b85-acce-7e2898a01b0a"}

The body is one line on the wire; it is wrapped here to fit. data uses the same field names as /events, so a webhook and the feed can be matched without a translation table.

Verifying a delivery.

The signature is a hex HMAC-SHA256, keyed with your signing secret, over the timestamp header, a full stop, and the raw request body: {timestamp}.{body}. It arrives as v1=<hex>. Three things go wrong in practice, and the samples below handle all three:

  • Sign the bytes you received, before any JSON parsing. Re-serialising the parsed body changes the bytes and the signature will never match.
  • Compare in constant time, not with ==, which leaks how many characters matched.
  • Refuse a timestamp more than five minutes from your clock. That stops someone replaying a request copied from a log.

The header can carry two values, separated by a comma. That happens while a secret is being rotated; accept the request if either matches.

Python 3Pass the raw body as bytes
import hashlib
import hmac
import time


def verify(body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
    timestamp = headers.get("X-GPStrix-Timestamp", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()
    for candidate in headers.get("X-GPStrix-Signature", "").split(","):
        scheme, _, value = candidate.strip().partition("=")
        if scheme == "v1" and value.isascii() and hmac.compare_digest(value, expected):
            return True
    return False


# Flask, for example:
#   if not verify(request.get_data(), request.headers, SIGNING_SECRET):
#       abort(401)
Node.js 18+Use express.raw, not express.json
import crypto from "node:crypto";
import express from "express";

function verify(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = headers["x-gpstrix-timestamp"] ?? "";
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  return (headers["x-gpstrix-signature"] ?? "").split(",").some((candidate) => {
    const [scheme, value = ""] = candidate.trim().split("=");
    const a = Buffer.from(value);
    const b = Buffer.from(expected);
    return scheme === "v1" && a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

const app = express();
app.post("/gpstrix", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers, process.env.GPSTRIX_SIGNING_SECRET)) {
    return res.sendStatus(401);
  }
  const delivery = JSON.parse(req.body.toString("utf8"));
  // Store delivery.id first and skip it if you have seen it before.
  res.sendStatus(204);
});
app.listen(3000);

Both functions were checked against a delivery signed by the GPStrix code itself: with the current secret, with the previous one during rotation, with a wrong secret and with a changed body.

Retries, duplicates and replay.

Answer with any 2xx status within 10 seconds. Anything else counts as a failure: another status, a timeout, a refused connection or a TLS error. A failed delivery is tried again on this schedule, six attempts in all, after which it is marked dead and stays in the delivery log.

  1. 1

    At once

    As soon as the event reaches us.

  2. 2

    About 30 seconds later

    Every delay may be pushed up to 20% later, never earlier, so a thousand failed deliveries do not all retry in the same second.

  3. 3

    About 2 minutes later

  4. 4

    About 10 minutes later

  5. 5

    About 1 hour later

  6. 6

    About 6 hours later

    The last try, a little over seven hours after the first. Long enough to sit out a deploy or an expired certificate.

Duplicates
Delivery is at least once, so the same event can arrive twice. X-GPStrix-Delivery (the same value as id in the body) is identical on every attempt of one delivery: store it and ignore one you have already processed.
Redirects
Not followed. A 301 or 302 fails the delivery at once and is not retried, because it will answer the same way six times. Give us the final URL.
Addresses
HTTPS only. URLs that resolve to private or internal addresses are refused.
Switched off
After 20 failures in a row the endpoint is turned off and the account's email contact is told why. It stays off until you turn it back on, so a broken endpoint cannot look healthy.
Rotating the secret
Rotating gives you a new secret, shown once. The old one keeps signing alongside it for 24 hours by default (you can choose 1 to 168), so you can deploy the new secret without losing deliveries in between.
Testing
The console's test button queues a delivery with the event type webhook.test, even to an endpoint that is switched off.
Delivery log
Every attempt is listed with its status code or error. Any delivery can be sent again with one click; the original record is kept as it was.

Webhooks are a plan feature (api.webhooks), and the number of live endpoints is a plan limit. Endpoints we switched off do not count towards it.

What v1 does not do.

It cannot change anything. There are no write routes: you cannot create a vehicle, move a device or send a command through the API. Every write in GPStrix is recorded against the person who made it, and an API key is not a person, so writes wait until that record can name a key properly.

In practice that means a Zapier or Make scenario can be started by a GPStrix event, through a webhook, and can read anything through the API. It cannot act on GPStrix.

If your integration needs something v1 does not have, tell us what it is. We would rather hear it now than guess.

Need API access on your account?

The API and webhooks are part of the plan. Tell us what you want to connect and we will set up the account and quote for it.

Talk to us

Already a customer? Sign in to the console.