saqr

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 EssentialProfessionalCommand
    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_request A parameter is missing or out of range — including a limit above the page size.
    • 401 unauthenticated No key, a revoked key, or a key sent in the URL instead of the header.
    • 403 insufficient_scope The key exists but was not given this scope.
    • 403 plan_required A write on a plan where the API is read-only.
    • 404 not_found The id does not exist, or belongs to another account. We do not say which.
    • 409 idempotency_conflict The same Idempotency-Key was sent with a different body.
    • 429 rate_limited More than 600 requests in a minute on one key. Retry-After says when.
    • 503 unavailable Planned maintenance or an incident. The status page will already say so.

    Webhooks

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