Developers

NovaTrack API

A plain JSON REST API for your tags, their history and everything that happens to them, plus signed webhooks so your systems hear about it straight away.

Base URL https://track.novasys.co.za/api/v1

Quickstart

  1. 1

    Create an API token

    Sign in and open Developers in the dashboard. Create a token and copy it; it is only shown once. A token acts for your whole team.

  2. 2

    List your tags

    curl "https://track.novasys.co.za/api/v1/devices" \
      -H "Authorization: Bearer $NOVATRACK_TOKEN" \
      -H "Accept: application/json"
  3. 3

    Subscribe to events

    Register a webhook for the events you care about, then verify the signature on every delivery.

Authentication

Send your token as a bearer token on every request, and ask for JSON. Requests without a valid token get 401. Keep tokens on your server; never put them in a browser or mobile app.

Authorization: Bearer 12|pQ3x…your-token…
Accept: application/json

All timestamps are ISO 8601 with a timezone offset. Ids in paths belong to your team; ids from another team return 404 rather than 403, so they cannot be probed.

Errors

200 / 201 Success. 201 when something was created.
204 Success with no body (deletes, ending a job).
401 Missing or invalid token.
404 Not found, or not on your team.
422 Validation failed. The errors object says which field and why.
{
    "message": "Zones must have a radius between 50 m and 5 km.",
    "errors": {
        "radius_meters": [
            "Zones must have a radius between 50 m and 5 km."
        ]
    }
}

Devices

A device is one AirTag on your account. Its latest known position is included on every device response.

List devices

GET /api/v1/devices

All tags on your team, ordered by name.

Query parameter Type Description
external_reference string Only return tags with this reference, for example your own asset number.

Request

curl -X GET "https://track.novasys.co.za/api/v1/devices?external_reference=VAN-003" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 200

{
    "data": [
        {
            "id": 42,
            "name": "Van 3",
            "emoji": "🚐",
            "color": "#6366f1",
            "kind": "airtag",
            "model": "AirTag (2nd generation)",
            "serial_number": null,
            "assignee_name": "Sipho M.",
            "external_reference": "VAN-003",
            "visibility_mode": "on_job",
            "is_active": true,
            "on_job_until": "2026-09-18T17:00:00+02:00",
            "battery_status": "full",
            "is_stale": false,
            "location": {
                "latitude": -26.1076,
                "longitude": 28.0567,
                "horizontal_accuracy": 25,
                "observed_at": "2026-09-18T09:41:12+02:00"
            },
            "last_fetched_at": "2026-09-18T09:45:00+02:00",
            "created_at": "2026-08-01T08:00:00+02:00"
        }
    ]
}

Get a device

GET /api/v1/devices/{device}

One tag by its id. Tags on other teams return 404.

Request

curl -X GET "https://track.novasys.co.za/api/v1/devices/42" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 200

{
    "data": {
        "id": 42,
        "name": "Van 3",
        "emoji": "🚐",
        "color": "#6366f1",
        "kind": "airtag",
        "model": "AirTag (2nd generation)",
        "serial_number": null,
        "assignee_name": "Sipho M.",
        "external_reference": "VAN-003",
        "visibility_mode": "on_job",
        "is_active": true,
        "on_job_until": "2026-09-18T17:00:00+02:00",
        "battery_status": "full",
        "is_stale": false,
        "location": {
            "latitude": -26.1076,
            "longitude": 28.0567,
            "horizontal_accuracy": 25,
            "observed_at": "2026-09-18T09:41:12+02:00"
        },
        "last_fetched_at": "2026-09-18T09:45:00+02:00",
        "created_at": "2026-08-01T08:00:00+02:00"
    }
}

Update a device

PATCH /api/v1/devices/{device}

Change how a tag is labelled and tracked. Send only the fields you want to change.

Body parameter Type Description
name string Up to 100 characters.
emoji string|null Shown on the map pin. Up to 16 characters.
color string Hex colour like #6366f1.
assignee_name string|null Who has the tag. Up to 120 characters.
external_reference string|null Your own id for the asset. Up to 120 characters.
visibility_mode string always, working_hours or on_job.
notes string|null Up to 2000 characters.

Request

curl -X PATCH "https://track.novasys.co.za/api/v1/devices/42" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"assignee_name":"Thandi K.","visibility_mode":"working_hours"}'

Response 200

{
    "data": {
        "id": 42,
        "name": "Van 3",
        "emoji": "🚐",
        "color": "#6366f1",
        "kind": "airtag",
        "model": "AirTag (2nd generation)",
        "serial_number": null,
        "assignee_name": "Thandi K.",
        "external_reference": "VAN-003",
        "visibility_mode": "working_hours",
        "is_active": true,
        "on_job_until": "2026-09-18T17:00:00+02:00",
        "battery_status": "full",
        "is_stale": false,
        "location": {
            "latitude": -26.1076,
            "longitude": 28.0567,
            "horizontal_accuracy": 25,
            "observed_at": "2026-09-18T09:41:12+02:00"
        },
        "last_fetched_at": "2026-09-18T09:45:00+02:00",
        "created_at": "2026-08-01T08:00:00+02:00"
    }
}

Location history

Every stored position for a tag. History is kept for 90 days. Reports dropped by a tag's privacy mode are never stored, so they never appear here.

List locations

GET /api/v1/devices/{device}/locations

Positions between two moments, oldest first. Defaults to the last 24 hours.

Query parameter Type Description
from date Start of the window (ISO 8601). Defaults to 24 hours ago.
to date End of the window. Must be on or after from. Defaults to now.
limit integer 1 to 5000. Defaults to 1000.

Request

curl -X GET "https://track.novasys.co.za/api/v1/devices/42/locations?from=2026-09-18T06:00:00%2B02:00&limit=500" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 200

{
    "data": [
        {
            "latitude": -26.1076,
            "longitude": 28.0567,
            "horizontal_accuracy": 25,
            "battery_status": "full",
            "observed_at": "2026-09-18T07:12:40+02:00"
        },
        {
            "latitude": -26.0931,
            "longitude": 28.0412,
            "horizontal_accuracy": 40,
            "battery_status": "full",
            "observed_at": "2026-09-18T07:31:05+02:00"
        }
    ]
}

Jobs

Put a tag on a job from your ticketing or dispatch system. Starting a job opens the on-job tracking window (used by the on_job privacy mode) and, when you pass a site location, creates a zone around the site for this tag so you get enter and exit events. The job reference is your own id, such as a ticket number.

Start a job

POST /api/v1/devices/{device}/jobs

Opens the tracking window for the given number of hours (default 8). Starting a job with a reference that already exists on the tag replaces its zone.

Body parameter Type Description
reference required string Your job or ticket reference. Up to 120 characters.
name string|null Zone name. Defaults to "Job {reference}".
latitude number Site latitude. Required with longitude.
longitude number Site longitude. Required with latitude.
radius_meters integer 50 to 5000. Defaults to 150.
hours integer 1 to 72. Defaults to 8.

Request

curl -X POST "https://track.novasys.co.za/api/v1/devices/42/jobs" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"reference":"TCK-1042","latitude":-26.0453,"longitude":28.0161,"hours":8}'

Response 201

{
    "device": {
        "id": 42,
        "name": "Van 3",
        "emoji": "🚐",
        "color": "#6366f1",
        "kind": "airtag",
        "model": "AirTag (2nd generation)",
        "serial_number": null,
        "assignee_name": "Sipho M.",
        "external_reference": "VAN-003",
        "visibility_mode": "on_job",
        "is_active": true,
        "on_job_until": "2026-09-18T17:00:00+02:00",
        "battery_status": "full",
        "is_stale": false,
        "location": {
            "latitude": -26.1076,
            "longitude": 28.0567,
            "horizontal_accuracy": 25,
            "observed_at": "2026-09-18T09:41:12+02:00"
        },
        "last_fetched_at": "2026-09-18T09:45:00+02:00",
        "created_at": "2026-08-01T08:00:00+02:00"
    },
    "geofence": {
        "id": 8,
        "name": "Job TCK-1042",
        "device_id": 42,
        "latitude": -26.0453,
        "longitude": 28.0161,
        "radius_meters": 150,
        "color": "#f59e0b",
        "notify_on_enter": true,
        "notify_on_exit": true,
        "external_reference": "TCK-1042",
        "expires_at": "2026-09-18T17:00:00+02:00"
    }
}

End a job

DELETE /api/v1/devices/{device}/jobs/{reference}

Removes the job zone. The on-job window closes when no other job on the tag is still open.

Request

curl -X DELETE "https://track.novasys.co.za/api/v1/devices/42/jobs/TCK-1042" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 204

(no content)

Geofences

Circular zones. When a tag crosses a zone boundary you get a geofence.enter or geofence.exit event. A zone with a device_id only applies to that tag; without one it applies to every tag.

List geofences

GET /api/v1/geofences

All zones on your team, ordered by name.

Request

curl -X GET "https://track.novasys.co.za/api/v1/geofences" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 200

{
    "data": [
        {
            "id": 7,
            "name": "Midrand depot",
            "device_id": null,
            "latitude": -25.9992,
            "longitude": 28.1263,
            "radius_meters": 200,
            "color": "#10b981",
            "notify_on_enter": true,
            "notify_on_exit": true,
            "external_reference": null,
            "expires_at": null
        }
    ]
}

Create a geofence

POST /api/v1/geofences

Adds a zone.

Body parameter Type Description
name required string Up to 120 characters.
latitude required number -90 to 90.
longitude required number -180 to 180.
radius_meters required integer 50 to 5000.
device_id integer|null Limit the zone to one of your tags.
color string|null Hex colour like #10b981.
notify_on_enter boolean Email the alert address when a tag enters.
notify_on_exit boolean Email the alert address when a tag leaves.
external_reference string|null Your own id. Up to 120 characters.
expires_at date|null Must be in the future.

Request

curl -X POST "https://track.novasys.co.za/api/v1/geofences" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name":"Midrand depot","latitude":-25.9992,"longitude":28.1263,"radius_meters":200,"color":"#10b981","notify_on_enter":true,"notify_on_exit":true}'

Response 201

{
    "data": {
        "id": 7,
        "name": "Midrand depot",
        "device_id": null,
        "latitude": -25.9992,
        "longitude": 28.1263,
        "radius_meters": 200,
        "color": "#10b981",
        "notify_on_enter": true,
        "notify_on_exit": true,
        "external_reference": null,
        "expires_at": null
    }
}

Delete a geofence

DELETE /api/v1/geofences/{geofence}

Removes the zone.

Request

curl -X DELETE "https://track.novasys.co.za/api/v1/geofences/7" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 204

(no content)

Events

Things that happened to your tags: zone crossings, tags going quiet and low batteries. Handy for polling if you cannot receive webhooks. Location updates are not stored as events; use location history or the location.updated webhook for those.

List events

GET /api/v1/events

The latest 200 events, newest first.

Query parameter Type Description
since date Only events after this moment. Store the newest occurred_at you have seen and pass it next time.
device_id integer Only events for this tag.

Request

curl -X GET "https://track.novasys.co.za/api/v1/events?since=2026-09-18T08:00:00%2B02:00" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 200

{
    "data": [
        {
            "id": 311,
            "type": "geofence.enter",
            "summary": "🚐 Van 3 entered Job TCK-1042",
            "device_id": 42,
            "geofence_id": 8,
            "payload": {
                "geofence_name": "Job TCK-1042",
                "geofence_reference": "TCK-1042"
            },
            "occurred_at": "2026-09-18T09:41:12+02:00"
        },
        {
            "id": 305,
            "type": "device.battery_low",
            "summary": "🪜 Ladder 7 battery is low",
            "device_id": 51,
            "geofence_id": null,
            "payload": {
                "battery_status": "low"
            },
            "occurred_at": "2026-09-18T08:02:55+02:00"
        }
    ]
}

Webhook endpoints

Register HTTPS URLs to receive events as they happen. See Webhooks below for the payload and how to verify signatures.

List webhooks

GET /api/v1/webhooks

Your registered endpoints and the HTTP status of their last delivery.

Request

curl -X GET "https://track.novasys.co.za/api/v1/webhooks" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 200

{
    "data": [
        {
            "id": 3,
            "url": "https://example.co.za/hooks/novatrack",
            "events": [
                "geofence.enter",
                "geofence.exit"
            ],
            "is_active": true,
            "last_status": 200
        }
    ]
}

Create a webhook

POST /api/v1/webhooks

The signing secret is only returned once, in this response. Store it safely.

Body parameter Type Description
url required string Must use https. Up to 500 characters.
events required array One or more event names, or "*" for all of them.

Request

curl -X POST "https://track.novasys.co.za/api/v1/webhooks" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.co.za/hooks/novatrack","events":["geofence.enter","geofence.exit"]}'

Response 201

{
    "id": 3,
    "url": "https://example.co.za/hooks/novatrack",
    "events": [
        "geofence.enter",
        "geofence.exit"
    ],
    "secret": "whsec_4f1c0b7e9d2a…"
}

Delete a webhook

DELETE /api/v1/webhooks/{webhook}

Stops deliveries to the endpoint.

Request

curl -X DELETE "https://track.novasys.co.za/api/v1/webhooks/3" \
  -H "Authorization: Bearer $NOVATRACK_TOKEN" \
  -H "Accept: application/json"

Response 204

(no content)

Webhooks

Events

Subscribe a webhook to any of these, or to * for all of them. The event name is also sent in the NovaTrack-Event header.

geofence.enter A tag entered a zone.
geofence.exit A tag left a zone.
device.stale A tag has not been seen for longer than your team’s “not seen” threshold.
device.battery_low A tag’s battery dropped to low or critical.
location.updated A new position was stored for a tag. This can be frequent.

Payload

Each delivery is a POST with a JSON body. device identifies the tag; data depends on the event. Stored events carry data.event_id (the same id as in List events) so you can de-duplicate, and data.geofence, which is null for events that are not about a zone. device.stale adds last_seen_at; device.battery_low adds battery_status.

geofence.exit

{
    "event": "geofence.exit",
    "occurred_at": "2026-09-18T16:02:31+02:00",
    "device": {
        "id": 42,
        "name": "Van 3",
        "external_reference": "VAN-003"
    },
    "data": {
        "event_id": 318,
        "geofence": {
            "id": 8,
            "name": "Job TCK-1042",
            "reference": "TCK-1042"
        },
        "geofence_name": "Job TCK-1042",
        "geofence_reference": "TCK-1042"
    }
}

location.updated

{
    "event": "location.updated",
    "occurred_at": "2026-09-18T16:05:10+02:00",
    "device": {
        "id": 42,
        "name": "Van 3",
        "external_reference": "VAN-003"
    },
    "data": {
        "latitude": -26.0512,
        "longitude": 28.0233,
        "horizontal_accuracy": 30,
        "observed_at": "2026-09-18T16:04:48+02:00"
    }
}

Headers

Content-Type: application/json
User-Agent: NovaTrack-Webhooks/1.0
NovaTrack-Event: geofence.exit
NovaTrack-Signature: t=1789740151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Verifying signatures

Every delivery is signed with your webhook’s secret so you can be sure it came from NovaTrack and was not changed on the way. The NovaTrack-Signature header looks like t=<unix time>,v1=<signature>.

  1. Split the header on commas and take t and v1.
  2. Build the signed string: the value of t, a full stop, then the raw request body exactly as received.
  3. Compute an HMAC-SHA256 of that string with your webhook secret, as lowercase hex.
  4. Compare it to v1 with a constant-time comparison. Reject timestamps more than five minutes old to block replays.

PHP

<?php

$secret = getenv('NOVATRACK_WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_NOVATRACK_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
    $parts[$key] = $value;
}

$timestamp = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);

if (! hash_equals($expected, $parts['v1'] ?? '') || abs(time() - $timestamp) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($payload, true);
// handle $event['event'] ...

http_response_code(200);

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.NOVATRACK_WEBHOOK_SECRET;

// Use the raw body: re-serialising parsed JSON breaks the signature.
app.post('/hooks/novatrack', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('NovaTrack-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((pair) => pair.split('=')));
  const timestamp = Number(parts.t);

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${req.body}`)
    .digest('hex');

  const valid = typeof parts.v1 === 'string'
    && parts.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));

  if (!valid || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body);
  // handle event.event ...

  res.sendStatus(200);
});

app.listen(3000);

Retries

  • Reply with any 2xx status within 10 seconds to acknowledge a delivery. Do slow work after you respond.
  • Anything else, or a timeout, counts as a failure. We try up to 5 times in total, waiting about 30 seconds, 2 minutes, 10 minutes and 30 minutes between attempts.
  • Deliveries can arrive more than once or out of order. Use data.event_id and occurred_at to de-duplicate and order them.
  • An endpoint that fails 20 deliveries in a row is switched off. Its is_active flag shows false in List webhooks.

Stuck, or need an endpoint we do not have yet? Email support@novasys.co.za.