saqr

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.

400
How fresh does it need to be?
Use

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.

Asking for each vehicle in turn400/min · 67% of limit
One cursor feed, 1,000 rows a page2.0/min · 0.33% of limit

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.

receiver down 48 h replay ends · 72 h
  1. #1 · 30 s
  2. #2 · 3 min
  3. #3 · 13 min
  4. #4 · 43 min
  5. #5 · 1.7 h
  6. #6 · 4.7 h
  7. #7 · 10.7 h
  8. #8 · 22.7 h
  9. #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.started Ignition on and moving
  • trip.completed Ignition off for two minutes
  • geofence.entered / exited Crossing a boundary you drew
  • alert.raised Any alert rule, with its severity
  • unit.silent No data past the stale threshold
  • job.status A 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

EssentialProfessionalCommand
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.