tractive-client
v1.0.0
Published
TypeScript client for Tractive's unofficial REST API (graph.tractive.com). Zero runtime dependencies.
Maintainers
Readme
Tractive Client
A small TypeScript client library for Tractive's unofficial REST API (graph.tractive.com).
Tractive has no official public API, so this talks to the same backend their web dashboard (my.tractive.com) uses.
This is a plain importable library - no server, no framework required. You bring your own credentials and wire it into your own application. Zero runtime dependencies.
Install
npm install tractive-clientSetup (for local development on this package itself)
npm install # also builds dist/ via the prepare script
npm run build # rebuild after making changes
npm run typecheck
npm testUsage
import { createTractiveClient } from "tractive-client";
const client = createTractiveClient({
email: "[email protected]",
password: "your-tractive-password",
// Both optional - default to the values from tractive that are known working.
// Override only if you have your own X-Tractive-Client value or Tractive
// changes their API base URL (e.g. a version bump from /4/ to /5/).
clientId: "your-x-tractive-client-header-value",
baseUrl: "https://graph.tractive.com/4/",
});
const trackers = await client.getTrackers();
const location = await client.getTrackerLocation(trackers[0]);Config
| Field | Required | Description |
|---|---|---|
| email | yes | Tractive account email |
| password | yes | Tractive account password |
| clientId | no | The X-Tractive-Client header value Tractive's API requires alongside the token. Defaults to a confirmed-working value |
| baseUrl | no | Base URL of Tractive's API. Defaults to https://graph.tractive.com/4/ |
A note on authentication
Every method calls authenticate() internally before making a request:
- If there's a valid cached token in memory (on this specific
clientinstance), reuse it - no network call. - Otherwise log in fresh against
POST {baseUrl}/auth/tokenusingemail/password, cache the result in memory, and use that.
A token is treated as expired 60 seconds before its real expires_at, so a request never straddles an expiry mid-flight. You never need to call authenticate() yourself first - every other method does it on demand. The cache is a plain closure variable private to that one client - it's not a module-level singleton, so multiple createTractiveClient() instances (e.g. multiple accounts) never interfere with each other.
A note on security
Tractive only supports email/password authentication so these credentials will always be required. It is highly advised to not use this package in any frontend application that bundles code to the client.
API
Returned by createTractiveClient(config).
Full parameter and return types ship in the package's .d.ts files, so your editor will show them on hover - this section documents behavior the types alone don't capture: what each call does under the hood, when it throws, and any caching/fallback logic.
Auth
authenticate()
Returns: Promise<TractiveAuth> - { user_id, client_id, expires_at, access_token }
Logs in (or reuses a cached token) and returns the raw auth response. Every other method calls this internally, so you only need it yourself if you want the raw token data. See How auth works.
isAuthenticated()
Returns: boolean
True if there's a still-valid cached token in memory. Never makes a network call.
Account
getMe()
Returns: Promise<TractiveMe> - { userId, email, firstName, lastName }
Curated, not a raw passthrough of Tractive's /user/{id} (which also returns phone number, home address, etc.).
Trackers
getTrackers()
Returns: Promise<string[]>
Tracker IDs associated with the account.
getTrackerDetails(trackerId)
Returns: Promise<TractiveTracker> - model, firmware, battery/charging state, capabilities.
Throws: TrackerNotFoundError if the tracker doesn't exist or isn't accessible.
getTrackerGeofences(trackerId)
Returns: Promise<unknown[]>
Raw geofence data. Not yet typed - TODO.
Location & history
getTrackerLocation(trackerId)
Returns: Promise<TractiveLocation> - latest GPS fix.
Falls back to the last successfully fetched value for that tracker if the live call fails.
Throws: TrackerNotFoundError only if nothing's cached yet.
getTrackerPositionRange(trackerId)
Returns: Promise<TractivePositionRange> - { first, last }, unix-second bounds of available history.
getTrackerPositionHistory(trackerId, timeFrom, timeTo)
Returns: Promise<TractivePositionHistoryPoint[][]>
GPS track as segments - a new segment marks a gap in tracking. timeFrom/timeTo are unix seconds.
reverseGeocode(latitude, longitude)
Returns: Promise<TractiveAddress> - { street, house_number, zip_code, city, country, full_address }
Hardware
getTrackerHardware(trackerId)
Returns: Promise<TractiveHardware>
Same stale-fallback behavior as getTrackerLocation.
Throws: TrackerNotFoundError only if nothing's cached yet.
getTrackerBattery(trackerId)
Returns: Promise<number> - 0-100.
Device commands (untested against real hardware)
setTrackerLED(trackerId, on)
setTrackerBuzzer(trackerId, on)
setTrackerLiveTracking(trackerId, on)
Returns: Promise<unknown> - Tractive's command-state object as-is, for all three: { active, started_at, timeout, remaining, pending }.
Known open issue: in testing, setTrackerLED(id, true) returned { pending: true, active: false, remaining: 0, ... } and the physical LED did not light up. Two live theories, neither confirmed:
- The tracker was in a power-saving state and hadn't checked in with Tractive's servers to pick up the queued command yet.
- The
clientIdin use has read access but may not have permission to issue device commands - Tractive may scope command-issuing to the official app's exact client ID differently from read-only data access.
Errors
NotAuthenticatedError- thrown if a valid access token can't be obtainedTrackerNotFoundError- thrown if a tracker has no data available (cached or live)
Architecture
The library is split into one folder per domain, each folder owning its own implementation, types, and test file together, rather than one large client file (or a flat src/ with same-named files sitting loose next to each other):
src/
index.ts - main entry point (barrel export)
account/
account.ts - getMe()
account.test.ts
auth/
auth.ts - TractiveAuth type + createAuthContext(): token cache, login flow
auth.test.ts
client/
client.ts - createTractiveClient(config): composition root
client.test.ts
commands/
commands.ts - setTrackerLED(), setTrackerBuzzer(), setTrackerLiveTracking()
commands.test.ts
errors/
errors.ts - NotAuthenticatedError, TrackerNotFoundError (shared, not owned by any one domain)
geocode/
geocode.ts - reverseGeocode()
geocode.test.ts
hardware/
hardware.ts - getTrackerHardware(), getTrackerBattery(), stale-hardware fallback cache
hardware.test.ts
http/
http.ts - TractiveContext: get()/post() request client every domain module uses
http.test.ts
location/
location.ts - getTrackerLocation(), getTrackerPositionRange(), getTrackerPositionHistory(), stale-location fallback cache
location.test.ts
trackers/
trackers.ts - getTrackers(), getTrackerDetails(), getTrackerGeofences()
trackers.test.ts
test/
test-support.ts - fakeContext()/fakeAuth() shared by every domain's testshttp.ts's TractiveContext is built on native fetch (no HTTP library dependency) and handles query-param building, JSON parsing, and throwing on non-2xx responses in one place, so no domain module talks to fetch directly. auth.ts's createAuthContext() is what every other domain depends on for requireAccessToken(). errors.ts and test/test-support.ts are the two files that don't belong to a single domain - they're cross-cutting, used by several folders, which is why they get their own folders rather than living inside one domain's.
Cross-domain imports go up and back down (../auth/auth.js, ../errors/errors.js, etc.); within a domain, the module imports its own test support with a plain ./ (e.g. auth.test.ts imports createAuthContext from ./auth.js).
dist/- build output (gitignored, regenerated bynpm run build/ thepreparescript), mirroring this same folder structure (dist/auth/auth.js, etc.) excepttest/and every*.test.ts, both excluded viatsconfig.build.json. This is whatpackage.json'smain/types/exportspoint to.tsconfig.json/tsconfig.build.json- two configs: the base one is whattsc --noEmituses for typechecking (sees everything, including tests);.build.jsonextends it excluding tests (src/**/*.test.ts,src/test/**) - that's what actually ships todist/. Tests don't go through either config at runtime - see "Testing" above for whytsxruns them directly instead.
Each domain module is a small factory - createTrackersApi(ctx, auth), createLocationApi(ctx, auth), etc. - taking the shared HTTP context and auth context as its only dependencies, and returning just the methods for that domain. createTractiveClient is the only place that knows about all of them at once; adding a new domain (say, geofence management) means adding one new folder and one line in client/client.ts, not touching anything else. The public API - every method name on the object createTractiveClient() returns - is unchanged by this reorganization.
Endpoints discovered but not wired in
Found while reverse-engineering the dashboard's network traffic and JS bundle, deliberately left out:
GET /user/{userId}(full profile - phone, home address;getMe()gives a curated subset instead)GET /user/{userId}/subscriptions,/invoices,/shop_orders,/payment/roaming,/user/{userId}/shares,/share/{id}- billing/sharing data, sensitive, no real use case for a tracker clientGET /user/push_notification_settings,/pull-notifications- low valueGET /pet/{petId}andGET /weight_activity_history/{petId}- real pet profile/weight data confirmed working, but there's no known way to list pet IDs from the account or tracker (/user/{id}/pets,/tracker/{id}/petare both dead ends); the pet ID we have was read manually out of a dashboard URL
