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.
https://track.novasys.co.za/api/v1
Quickstart
-
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
List your tags
curl "https://track.novasys.co.za/api/v1/devices" \ -H "Authorization: Bearer $NOVATRACK_TOKEN" \ -H "Accept: application/json" -
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>.
- Split the header on commas and take
tandv1. - Build the signed string: the value of
t, a full stop, then the raw request body exactly as received. - Compute an HMAC-SHA256 of that string with your webhook secret, as lowercase hex.
- Compare it to
v1with 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_idandoccurred_atto de-duplicate and order them. - An endpoint that fails 20 deliveries in a row is switched off. Its
is_activeflag shows false in List webhooks.
Stuck, or need an endpoint we do not have yet? Email support@novasys.co.za.