The reference, and a sandbox that behaves like it.
Every rule on this page — the page size, the rate limit, what a plan may write, how a retried job is recognised — is also running in the explorer below, so you can break it here before you break it in production.
- api.nml.sa/fleet/v1
- Base URL, JSON over HTTPS
- 600/min
- Per key, failed calls included
- 1,000
- Rows a page, at most
- 72 h
- Webhook replay window
Try it
A sandbox of 12 vehicles and an hour of positions — 2,520 rows, so 3 pages at the maximum limit. Page through it with the cursor, send the same job twice, switch the plan to Professional and try to write, or send 650 calls at once and watch where the 429 starts.
GET https://api.nml.sa/fleet/v1/positions?limit=1000
Nothing sent yet. This runs the API's contract in your browser against a sandbox fleet — no key, no account, no network.
Authentication
One key per integration, created by an administrator, carrying only the scopes it needs. Keys go in the header; a key seen in a URL is treated as leaked and revoked.
curl https://api.nml.sa/fleet/v1/vehicles \
-H "Authorization: Bearer $SAQR_KEY" Scopes
- read:fleet
- write:drivers
- write:jobs
- write:geofences
- write:webhooks
- read:exports
Write scopes can be granted on any plan but only work on Command; elsewhere they return plan_required.
Pagination
Cursors, not page numbers. A cursor marks a position in the stream, so rows that arrive while you page — a backfill after a dead zone, say — land after it instead of shifting every page by one.
let since = localStorage.cursor; // or wherever you keep it
let page;
do {
const r = await fetch(`https://api.nml.sa/fleet/v1/positions?limit=1000${since ? "&since=" + since : ""}`,
{ headers: { Authorization: `Bearer ${key}` } });
if (r.status === 429) { await sleep(Number(r.headers.get("Retry-After")) * 1000); continue; }
page = await r.json();
await save(page.data); // save first…
since = localStorage.cursor = page.next; // …then move the cursor
} while (page.has_more); Save the rows before you save the cursor. The other order loses a page whenever the process dies in between.
Endpoints
| Endpoint | Scope | Essential | Professional | Command |
|---|---|---|---|---|
| Fleet | ||||
GET/vehicles Vehicles, with unit serial, plan and last position. | read:fleet | |||
GET/vehicles/{id} One vehicle. | read:fleet | |||
GET/drivers Drivers and their current assignment. | read:fleet | |||
POST/drivers Create or update a driver from your HR system. | write:drivers | |||
| Telemetry | ||||
GET/positions Every fix since a cursor, across the fleet. | read:fleet | |||
GET/trips Trips in a time range, with distance and driver. | read:fleet | |||
GET/events The same events webhooks deliver, for catching up. | read:fleet | |||
| Operations | ||||
POST/jobs Push a job to a vehicle. Requires an Idempotency-Key. | write:jobs | |||
PATCH/jobs/{id} Move a window, change a stop, or cancel. | write:jobs | |||
GET/geofences Geofences and their alert rules. | read:fleet | |||
POST/geofences Create a geofence from a polygon. | write:geofences | |||
| Delivery | ||||
POST/webhooks/replay Re-send events from a point in the last 72 hours. | write:webhooks | |||
GET/exports Scheduled export files and their status. | read:exports | |||
Errors
Always { "error": { "code", "message" } }. Branch on the code; the message is for people and may change.
- 400
invalid_requestA parameter is missing or out of range — including a limit above the page size. - 401
unauthenticatedNo key, a revoked key, or a key sent in the URL instead of the header. - 403
insufficient_scopeThe key exists but was not given this scope. - 403
plan_requiredA write on a plan where the API is read-only. - 404
not_foundThe id does not exist, or belongs to another account. We do not say which. - 409
idempotency_conflictThe same Idempotency-Key was sent with a different body. - 429
rate_limitedMore than 600 requests in a minute on one key. Retry-After says when. - 503
unavailablePlanned maintenance or an incident. The status page will already say so.
Webhooks
-
trip.startedIgnition on and moving -
trip.completedIgnition off for two minutes -
geofence.entered / exitedCrossing a boundary you drew -
alert.raisedAny alert rule, with its severity -
unit.silentNo data past the stale threshold -
job.statusA driver moves a job along in the app
Verifying the signature — show
// Every webhook carries: Saqr-Signature: t=<unix seconds>,v1=<hex>
// v1 is HMAC-SHA256 of "<t>.<raw body>" with your endpoint secret.
async function verify(raw: string, header: string, secret: string) {
const { t, v1 } = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string])
);
// Reject anything older than five minutes: a captured request replayed later.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${raw}`));
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
// Compare every character, so the time taken says nothing about where they differ.
let diff = hex.length ^ v1.length;
for (let i = 0; i < hex.length; i++) diff |= hex.charCodeAt(i) ^ (v1.charCodeAt(i) || 0);
return diff === 0;
}
// Deliveries are at-least-once: store evt ids and ignore a second arrival. Position fields
- position
- speed
- heading
- ignition
- odometer
- fuel_pct
- driver_id
- accel
- gsm
- recorded_at
- received_at
Versioning, in four rules
Adding is not breaking
New fields, new endpoints and new event types arrive without notice. Ignore fields you do not recognise.
Breaking means a new version
Removing or renaming anything, or changing a type, happens only in v2 — never inside v1.
Twelve months, then off
When a version is deprecated it keeps working for twelve months, and every response carries a Sunset header with the date.
We tell the key's owner
Deprecation notices go to the administrator who created each key still calling the old version, not to a mailing list.