# @fleeta/sdk

Official JavaScript/TypeScript client for the [Fleeta Open API](https://developers.fleeta.io) —
fleet dashcam data, GPS tracking, safety events, video and driving reports.
**Zero dependencies** (built on the `fetch` that ships with Node 18+), works in Node and in the browser.

## Install

```bash
npm install @fleeta/sdk
```

## Quick start

```js
const { FleetaClient, OpenApiError } = require('@fleeta/sdk');

const fleeta = new FleetaClient({
  apiKey: 'flt_live_xxxxxxxxxxxxxxxx', // issued in the Fleeta web viewer: Management › Open API
  // baseUrl defaults to https://openapi.fleeta.io
});

// Dashcams that have not connected for 30 days (server-side filter)
const { data, pagination } = await fleeta.devices.list({ lastConnectedBefore: '30d', perPage: 50 });

// Create a GPS export job
const job = await fleeta.telemetry.createExportJob({
  from: '2026-06-01T00:00:00Z', to: '2026-07-01T00:00:00Z', format: 'csv',
});

// Walk every safety event — the SDK follows the cursor for you
// One event is one row — ev.channels lists every angle it recorded (['front','rear'])
for await (const ev of fleeta.events.iterate({ from: '2026-06-01T00:00:00Z' })) {
  console.log(ev.eventId, ev.type, ev.occurredAt, ev.channels);
}

// Thumbnails for a whole page in one request — no extra calls, no quota
const { data: page } = await fleeta.events.list({ limit: 20, include: 'thumbnail' });
page.forEach((ev) => console.log(ev.eventId, ev.thumbnailUrl));

// Errors are RFC 9457 problem+json, surfaced as OpenApiError
try {
  await fleeta.events.video(someEvent.eventId, { channel: someEvent.channels[0] });
} catch (e) {
  if (e instanceof OpenApiError) console.error(e.status, e.code, e.detail, e.requestId);
  // 0.6.0+: narrow the body by code — every extension field is typed (mirrors the spec's Problem<Code> schemas)
  if (e instanceof OpenApiError && e.is('cloud_only')) console.error('Wi-Fi-only dashcams:', e.body.psns);
}
```

No dashcam yet? Use the public sandbox key `flt_test_ent_0000000000000000001` — it reaches a demo
fleet with synthetic media, so every call in this README works before you own a device.
See [Sandbox](https://developers.fleeta.io/docs/guides/sandbox).

### Wi-Fi-only dashcams

`devices.list()` returns every dashcam in the organization, cloud-connected and Wi-Fi-only alike
(`category: 'cloud' | 'wifi'`). A Wi-Fi-only dashcam is always `offline`, and cloud-only features
(location, trips, events, video, SD card, recall, settings, reboot, GPS export) answer
`422 cloud_only` for its PSN — do not retry. Pass `{ category: 'cloud' }` to list only the dashcams
those features apply to.

```js
const cloudOnly = await fleeta.devices.list({ category: 'cloud' });
```

### Camera limit — over-limit dashcams are readable but locked

Seats go to the most recently registered cloud dashcams, up to the subscription's camera count. A
dashcam beyond it is `entitlement: 'over_limit'` (0.8.0+): it is still listed, and everything already
stored — location, trips, events, thumbnails, statistics, geofence alerts, GPS export — reads as
usual. Only **video URLs, SD-card access, recall and remote commands** answer `403 camera_limit_exceeded`,
as does `devices.create()` when a batch would exceed the count (all-or-nothing). Nothing to reconfigure:
raise the camera count in the Fleeta web viewer (Account › Subscription) and the same key picks it up
within 5 minutes. Sandbox keys are never locked.

```js
const { data: devices, cameras } = await fleeta.devices.list();   // cameras: { limit, registered, overLimit }
const locked = devices.filter((d) => d.entitlement === 'over_limit'); // or devices.list({ entitlement: 'over_limit' })

try {
  await fleeta.events.video(ev.eventId);
} catch (e) {
  if (e instanceof OpenApiError && e.is('camera_limit_exceeded')) {
    console.error(`raise the camera count to ${e.body.requiredCameraLimit} (now ${e.body.cameraLimit})`); // no Retry-After — waiting does not help
  }
}

// Driving reports and the fleet summary count covered dashcams by default (the web viewer's numbers);
// pass entitlement: 'all' to include over-limit dashcams. Device/event/location lists default to 'all'.
const everyone = await fleeta.insights.drivingReports({ entitlement: 'all' });
const { cameras: { overLimitPsns } } = await fleeta.admin.usage();     // exactly which dashcams are locked
```

### SD-card recordings — play a clip

`media.sdFileVideo()` returns a playable link for one recording straight from the dashcam's SD card
(0.7.0+). A recall job is created behind the scenes — `url` is that job's `downloadUrl`, and `jobId`
lets you poll progress or stop the upload. `quality` defaults to `'sub'` (the low-resolution copy meant
for playback); pass `'main'` for full resolution. Pass the **main** recording name exactly as listed by
`media.sdFiles()` — the sub-stream is selected with `quality`, never by appending `S` to the filename
(`…S.mp4` answers `422 invalid_field`).

> A `GET` with side effects: every call asks the dashcam to upload over its own LTE connection and
> counts toward the recall allowance (also when the media server already holds a copy). Request the
> link once and reuse it until `expiresAt` — never on every render. If the same file (same quality)
> was requested within 48 h and the copy is still on the media server (about one day), no mobile data
> is spent.

```js
const psn = '7XBPK0BE00000001';
const { data: files } = await fleeta.media.sdFiles(psn, { stream: 'main' });

const { data: clip } = await fleeta.media.sdFileVideo(psn, files[0].filename); // quality defaults to 'sub'
console.log(clip.url, clip.expiresAt);   // hand url to <video src> or fetch it as returned
// { url, expiresAt, jobId, sizeBytes, receivedBytes, quality }
// expiresAt: null = deadline unknown (not unlimited) · receivedBytes: null = not probed (always null in the sandbox)

// Upload progress / cancel through the job behind the link
const { data: job } = await fleeta.media.recallJob(clip.jobId);
await fleeta.media.cancelRecallJob(clip.jobId);

// Full resolution instead
await fleeta.media.sdFileVideo(psn, files[0].filename, { quality: 'main' });
```

### SD-card recall — busy retry and progress polling

`media.createRecallJob()` is the same flow as a job resource: use it when you want the job in hand
(download intent — `quality` defaults to `'main'` here). The `201` already carries a `downloadUrl`
that plays or downloads immediately; the file streams while the dashcam is still uploading.

A dashcam handles one transfer at a time. While another transfer is running, the API answers
`409 device_busy` (with a `Retry-After` header) and creates no job. Pass `retryOnBusy` and the SDK
retries for you, honoring the header. (`sdFileVideo()` answers the same `409` — retry it yourself
after `e.retryAfter` seconds.)

```js
// Success means status 'ready' with a downloadUrl right away (there is no queued state)
const { data: job } = await fleeta.media.createRecallJob(
  { psn: '7XBPK0BE00000001', filename: '20260727_131445_NF.mp4', quality: 'sub' },
  { retryOnBusy: true },                    // default: retry at the Retry-After interval (5 s), up to 60 s in total
  // { retryOnBusy: { timeoutMs: 120000 } } // adjust the total timeout
);
console.log(job.status, job.downloadUrl);

// Optional progress polling — receivedBytes is present only when the server could verify it
for (;;) {
  const { data: j } = await fleeta.media.recallJob(job.jobId);
  if (j.receivedBytes !== undefined && j.sizeBytes) {
    console.log(`received ${Math.round((j.receivedBytes / j.sizeBytes) * 100)}%`);
    if (j.receivedBytes >= j.sizeBytes) break;
  }
  await new Promise((r) => setTimeout(r, 3000));
}

// Handling it yourself, without retryOnBusy:
try {
  await fleeta.media.createRecallJob({ psn, filename });
} catch (e) {
  if (e instanceof OpenApiError && e.code === 'device_busy') {
    // retry after e.retryAfter seconds; if it stays busy for minutes, fleeta.devices.reboot(psn) recovers the device
  }
}
```

### Low-level requests — the third argument is an envelope

Since 0.5.0 every one of the operations (52 as of 0.7.0) in the spec has a domain method. For an endpoint that
is newer than the SDK, call `request(method, path, opts)` directly. `opts` is an
**envelope `{ query, body }`**; passing the payload flat fails immediately with a `TypeError`
(before 0.5.0 the body was silently dropped and the server answered 422).

```js
await fleeta.request('GET', '/v1/groups');                                  // options may be omitted (= fleeta.devices.groups())
await fleeta.request('GET', '/v1/devices', { query: { perPage: 50 } });     // query string
await fleeta.request('POST', '/v1/media/recall-jobs', { body: { psn, filename } }); // request body

await fleeta.request('POST', '/v1/media/recall-jobs', { psn, filename });   // ✗ TypeError
```

## Domains

| Accessor | Resources |
|---|---|
| `fleeta.devices` | Devices: list / detail / update / pre-register / battery / firmware / settings (read) / reboot, groups (`groups()`, 0.5.0+) |
| `fleeta.media` | SD-card recordings: list / metadata / delete, playable link (`sdFileVideo()`, 0.7.0+); recall jobs: create / get / cancel |
| `fleeta.telemetry` | Latest locations (snapshot and feed), trips, GPS tracks, GPS export jobs |
| `fleeta.events` | Safety events: list / feed / detail / video and thumbnail URLs / statistics |
| `fleeta.geofences` | Geofence CRUD, alert history (`alerts()` · `iterateAlerts()`, 0.5.0+) |
| `fleeta.insights` | Fleet summary, driving reports |
| `fleeta.admin` | Audit logs, API call logs (`apiLogs()` · `iterateApiLogs()`, 0.5.0+), usage |
| `fleeta.webhooks` | Webhook subscriptions, test deliveries, delivery history; `reactivate` and secret rotation (0.6.0+) |

Every operation in the spec (`openapi.yaml`, 52 in total) has a method. The types are checked
against the spec field by field, so the SDK cannot drift from the API silently.

## Pagination

- **Cursor** resources (events · telemetry · geofence-alerts · admin · insights `GET /v1/reports/driving`):
  the response carries `pagination.nextCursor` / `hasMore`; the type is `CursorPage<T>`. Auto-iteration
  helpers exist for events (`iterate()`), geofence-alerts (`iterateAlerts()`), audit-logs
  (`iterateAuditLogs()`) and api-logs (`iterateApiLogs()`). For telemetry (`fleetLocations`, `trips`,
  `exportJobs`, …) and insights (`drivingReports`) pass `nextCursor` back as `after`.
- **Offset** resources (devices · sd-files · geofences · recall-jobs · webhooks · deliveries): the response
  carries `pagination.page` / `totalPages`. Use `iterate()` (webhooks: call `list({ page, perPage })`),
  or pass `{ page, perPage }` yourself. The type is `OffsetPage<T>`.

## TypeScript

`index.d.ts` is synchronized field by field with the 85 schemas of `openapi.yaml` (0.8.0). Response
envelopes are `Single<T>` (one item) and `Page<T>` (lists) — the six operations that carry dashcams (four lists
plus the `fleetSummary()` / `events.stats()` aggregates) add a `cameras: CameraCoverage` block beside `data`
(0.8.0+); the three 204 operations
(`webhooks.delete`, `geofences.delete`, `telemetry.cancelExportJob`) return `NoContent` (`{ data: null }`).
Every write method takes a typed request body (`WebhookCreateRequest`, `GpsExportJobCreateRequest`,
`GeofenceCreateRequest`, …).

```ts
import { FleetaClient, GeofenceAlert, WebhookCreated } from '@fleeta/sdk';

const { data: hook }: { data: WebhookCreated } = await fleeta.webhooks.create({ url, events: ['safety_event'] });
hook.secret; // visible only in the create response

for await (const a of fleeta.geofences.iterateAlerts({ type: 'speed' })) {
  const alert: GeofenceAlert = a; // location is GeoPoint | null (no field-level nulls)
}
```

Upgrading from 0.4.0: `RecallJobStatus` is now `'ready' | 'canceled'` (US spelling),
`devices.create()` returns a `DevicePreRegisterResult`, and `Usage.volumeQuotas` is an array —
the full list is in [CHANGELOG.md](./CHANGELOG.md). 0.6.0 adds `OpenApiError.is(code)` / `isKnown()` /
`CODES`, the `Problem<Code>` types and `webhooks.reactivate()` / `rotateSecret()`; `Webhook` gains two
required-nullable fields (`suspendedAt`, `previousSecretExpiresAt`), so code that builds `Webhook`
literals by hand must add them — see [CHANGELOG.md](./CHANGELOG.md).

## Conventions

- Authentication: `Authorization: Bearer <apiKey>` (attached by the SDK).
- Time: every field is RFC 3339 UTC. Units: metric (km/h, m). GPS export ranges are UTC calendar days
  (`from` / `to` time of day is ignored); `insights.drivingReports()` takes `YYYY-MM-DD` or an RFC 3339
  date-time with offset (reduced to the UTC calendar date), while `drivingReport(psn)` takes `date` only.
- Errors: `application/problem+json` → `OpenApiError` (`status`, `code`, `detail`, `requestId`, `retryAfter`).
  `e.is(code)` narrows `e.body` to that code's fields (`ProblemCloudOnly.psns`, `ProblemQuotaExceeded.bucket`, …; 0.6.0+).
- 429: retried once after `Retry-After`. A monthly-cap 429 (`monthly_limit_exceeded`) is thrown immediately.
- 409 `device_busy`: a dashcam handles one transfer at a time. Thrown as-is by default —
  `media.createRecallJob(body, { retryOnBusy: true })` retries for you (see above).
- `request()` options: only `{ query, body }` — any other key is a `TypeError` (0.5.0+).

## Versions

- **0.8.0** (unpublished; **0.7.0 shipped on npm 2026-09-02 with only the first item below**) —
  `media.sdFileVideo(psn, filename, { quality? })` + `SdFileVideoUrl`
  (`GET /v1/devices/{psn}/sd-files/{filename}/video`, all 52 operations covered); recall-job comments
  reframed as play-or-download (`sizeBytes` = size of the requested stream). `geofence_unsupported_shape`
  (`409` on `geofences.update()` without a `shape`) and `query_timeout` (`504` on
  `events.stats()` over too wide a period) added to `ProblemCode` / `CODES`. Type
  comments: GPS export `from` / `to` are UTC calendar days, `insights.drivingReports()` accepts RFC 3339
  date-times, `drivingReport(psn)` rejects `from` / `to` with `400`. **Camera-limit gate** (2026-09-08):
  `403 camera_limit_exceeded` (`ProblemCameraLimitExceeded`; `CODES` now 53 codes), `entitlement` on every
  dashcam row plus `registeredAt` / `lastLoginAt` / `connectivity` / `cameraPosition` on devices, a
  `cameras` block beside `data` on six operations (four lists and two aggregates), a typed `entitlement` filter; `insights.drivingReports()` /
  `fleetSummary()` count covered dashcams by default (`{ entitlement: 'all' }` for everything). `RecordingType`
  vocabulary change and the new required row fields are the only things that can break a build — see `CHANGELOG.md`.
- **0.6.0** — `webhooks.reactivate()` / `webhooks.rotateSecret()`, typed `Problem<Code>` errors
  (`error.is(code)` / `isKnown()` / `CODES`), `WebhookDelivery.eventKey` (all 51 operations covered).
- **0.5.1** — English documentation, type comments and error messages. No API change.
- **0.5.0** — `request()` envelope validation (a flat payload is now a `TypeError`). **Type declarations
  synchronized** with the 56 spec schemas: `getSettings` declared, `WebhookCreated.secret`,
  `DevicePreRegisterResult`, `GeofenceAlert`, non-null `GeoPoint`, `Usage` (`callHistory` /
  `statusBreakdown` / `volumeQuotas[]`), real types for export jobs, event media, reboot and webhook
  test/deliveries, `NoContent` for 204 operations, 7 request types. **New methods** — `devices.groups()`,
  `admin.apiLogs()` / `iterateApiLogs()`, `geofences.iterateAlerts()` (all 49 operations covered).
  `DeviceCategory` is `'cloud' | 'wifi'` again and cloud-only features answer `422 cloud_only` for a
  Wi-Fi-only PSN. **Breaking** — `RecallJobStatus` `'cancelled'` → `'canceled'`; the 429 retry is capped
  at 60 s and the monthly cap is thrown immediately. Full details in `CHANGELOG.md`.
- **0.4.0** — 409 `device_busy` redesign + `retryOnBusy`.

## Links

- Developer portal: https://developers.fleeta.io — [Getting Started](https://developers.fleeta.io/docs/getting-started) · [API Reference](https://developers.fleeta.io/api-reference) · [Plans & Limits](https://developers.fleeta.io/docs/pricing)
- AI assistants (MCP): https://developers.fleeta.io/docs/ai — connect ChatGPT, Claude or Cursor to your fleet with the same API key
- Source: https://github.com/fleeta-io/sdk-js

MIT © Pittasoft Co., Ltd.
