Base URL: your workspace's API origin. All requests and responses are JSON unless noted. Times are ISO-8601 UTC instants.
Authentication
Mint an API key in Settings → API keys & webhooks and send
it as a bearer token. Keys are read-only by default; the
optional jobs:write scope additionally allows creating and
updating jobs — and nothing else.
curl -H "Authorization: Bearer sxk_..." https://api.visitstracker.com/visits
Reads (all keys)
| Endpoint | Returns |
|---|---|
GET /location/live | Latest position per tracked worker, quality-classified |
GET /location/trail?user_id&from&to | Fix stream + total/moving distance |
GET /location/nearest?lat&lng | Nearest workers by trusted position, with staleness |
GET /visits?user_id&from&to&customer_id | Derived + manual visits |
GET /visits/:id/photos | Photos and signatures on a visit |
GET /attendance?from | Punch sessions with site match + mock flags |
GET /jobs?date&user_id&status&days&limit&offset | Dispatch board. Windowed: without date it returns the last days (default 90) plus everything still ahead, limit 200 (max 500) — see the changelog |
GET /zones | Geofence zones |
GET /shifts?from&to | Planned rota |
GET /reports/mileage?days | Per-worker daily distance (+ device odometer delta) |
GET /reports/battery?days | Battery health per worker-day |
GET /reports/anomalies?date | Own-weekday anomaly findings |
GET /reports/rota?days | Planned vs actual with verdicts |
GET /reports/evidence?user_id&days | Weekly evidence PDF (binary) |
GET /reports/replay.svg?user_id&from&to | Animated route replay (SVG) |
GET /evidence-seals · /evidence-seals/:day/verify | Hash-chain seals; verify recomputes |
Writes (jobs:write scope)
| Endpoint | Body |
|---|---|
POST /jobs |
{customer_id, title, due_date, assigned_user_id?, window_start?, window_end?, recurs?, checklist?} |
PATCH /jobs/:id |
{status?, assigned_user_id?, notes?, recurs?, check_item?} |
Job status path: assigned → en_route → done | cancelled.
A job with an incomplete checklist refuses done; a derived
visit at the job's site auto-completes it.
Webhooks
Configured in Settings. Events: attendance.punch,
alert.fired (kinds: sos, mock_location, gone_dark,
job_missed_window), zone.transition. Payloads are signed:
X-SenseNxt-Signature: sha256=HMAC_SHA256(secret, rawBody)
X-SenseNxt-Event: alert.fired
{"event":"alert.fired","tenant_id":"…","at":"…",
"data":{"kind":"gone_dark","user":"Finn Field"}}
Verify by recomputing the HMAC over the exact raw body. Slack-format
hooks receive {"text": "…"} instead (unsigned-friendly, still
signed).
Public token surfaces
| Endpoint | What |
|---|---|
/public/share/:token(.json) | Expiring live-position page (ETA); 410 when expired/revoked |
/public/portal/:token(.json) | Standing per-customer service record (visits, photos, signatures) |
Errors
Non-2xx responses carry {"error": "machine_code", "message":
"human sentence"}. Notable codes: totp_required,
tenant_locked, week_approved,
checklist_incomplete, read_only,
rate_limited (with Retry-After).
Some refusals carry a detail object alongside the sentence,
for the cases where a client has to act on the numbers rather than print
them — site_has_open_jobs returns
{"open_jobs": 3, "first_due": "2026-09-21"}.
Changelog
Breaking changes are announced here before they ship, and this page is the record of what already did. If something you depend on is not written down, tell us — an undocumented behaviour you rely on is our problem, not yours.
| Date | Change |
|---|---|
| 2026-08-13 | Archiving a site with open jobs is refused
(409 site_has_open_jobs). Previously it succeeded and
left the jobs pointing at a site that no longer appeared in any list.
Pass {"archived": true, "force": true} to archive anyway
— the open jobs are cancelled, with a note saying why. |
| 2026-08-13 | GET /jobs rows carry site_archived, so a
job booked against a site archived earlier can be shown as such. |
| 2026-08-13 | GET /evidence-seals now returns
state (verified · unchecked ·
broken · expired),
last_verified_at and a top-level broken
list. /:day/verify answers intact: null with
status: "expired" for a day whose fixes have passed the
workspace's retention window — previously such a day reported as
broken, which read as tampering. |
| 2026-08-13 | Decisions on leave, expenses and timesheet amendments are
conditional writes: a second decider racing the first now gets
409 decided / 409 already_decided instead of
silently overwriting. |
| 2026-08-13 | Passwords must be 10+ characters, off the common
list, and must not contain the person's name, address or workspace
(400 weak_password). Existing passwords are unaffected
until they are next changed. Ten failed sign-ins lock an account for
fifteen minutes (423 account_locked for a caller with the
right password; guessers still see 401
bad_credentials). |
| 2026-08-11 | GET /jobs became windowed: without
date it returns the last days (default 90)
plus everything still ahead, limit 200 (max 500). It
previously returned every job ever created. |