Ask, be told, or take the lot.
There are three ways fleet data reaches your systems, and most integrations that go wrong picked the wrong one — a live map built by polling every vehicle, or a warehouse fed by webhooks that loses a day whenever the receiver restarts. Here is how to choose, and the arithmetic behind it.
REST API
Positions, trips, events and assets. Cursor-paginated, rate-limited, documented.
Webhooks
Signed, retried with backoff, replayable for 72 hours.
ERP and dispatch
Push jobs in, pull proof-of-delivery and utilisation back.
Data export
Scheduled CSV or Parquet to your own bucket. Your data stays yours.
Which one, for your fleet
The first integration most teams write asks for each vehicle's position in turn. It works on the test fleet and falls over on the real one. Move the slider to where yours stops fitting inside 600 requests a minute.
Poll the feed with a cursor
One call returns everything that changed since the last one, across the whole fleet. Simple to build, simple to debug, and nothing is missed between calls.
Rate limit: 600 requests a minute per key
- Webhook events a day
- 13,600
- Daily export
- 1,440,000 rows · 20 MB
What the calls look like
Ask: the cursor feed
One call, the whole fleet, everything since last time.
GET https://api.nml.sa/fleet/v1/positions?since=cur_7Hd91K&limit=1000
200 OK
{ "data": [ …up to 1000 rows… ],
"next": "cur_7Hd9Qx", // pass this as ?since next time
"has_more": false } // true: call again straight away Tell us: push a job
Retry freely — the idempotency key means a job is created once.
POST https://api.nml.sa/fleet/v1/jobs
Idempotency-Key: ERP-SO-448213
{ "ref": "SO-448213", "vehicle": "VAN-042",
"stop": { "position": [21.5433, 39.1728], "window": ["10:00", "12:00"] } } Be told: a webhook
Signed, with the recorded and received times both kept.
{
"id": "evt_01J8Q4Z6K3",
"type": "trip.completed",
"vehicle": {
"id": "TRK-114",
"plate": "4821 RSA"
},
"trip": {
"started_at": "2026-09-14T05:12:40+03:00",
"ended_at": "2026-09-14T08:47:10+03:00",
"km": 212.4,
"driver_id": "DRV-3107"
},
"last_position": {
"position": [
24.7136,
46.6753
],
"speed": 0,
"ignition": false,
"recorded_at": "2026-09-14T08:47:10+03:00",
"received_at": "2026-09-14T08:47:12+03:00"
}
} Base URL · https://api.nml.sa/fleet/v1
When your endpoint is down for the weekend
A failed delivery is retried 9 times, further apart each time, the last one 46.7 h after the first failure. Everything stays replayable for 72 hours.
Retries alone do not cover a 48-hour weekend outage — they stop 1.3 hours short. The replay window does, with 24 hours to spare: on Sunday morning, replay from Thursday night and nothing is missing.
- #1 · 30 s
- #2 · 3 min
- #3 · 13 min
- #4 · 43 min
- #5 · 1.7 h
- #6 · 4.7 h
- #7 · 10.7 h
- #8 · 22.7 h
- #9 · 46.7 h
Check it came from us
Standard HMAC, no library required. Reject stale timestamps and compare in constant time, and a captured request cannot be replayed at you.
// 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. Events
-
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
Every position carries
- position
- speed
- heading
- ignition
- odometer
- fuel_pct
- driver_id
- accel
- gsm
- recorded_at
- received_at
On which plan
| Essential | Professional | Command | |
|---|---|---|---|
| REST API and webhooks | Read-only | Read-only | Full |
| Scheduled data export |
The same rows as the pricing table, read from it. Scheduled export is Command only; a one-off export of your full history is on every plan, because it is your data.
What we will and will not promise
- At least once, not exactly once
- A webhook can arrive twice after a retry. Every event has an id; keep the ones you have seen.
- Order is not guaranteed
- A backfill after a dead zone delivers old positions late. Sort by recorded_at, not by when they arrived.
- We do not build your integration
- We document, answer questions and look at your logs with you. The code on your side is yours, and so is its roadmap.
- Writes are Command only
- Reading is on every plan. Pushing jobs, drivers and geofences in, and the scheduled export, need Command.