Skip to main content

GPS Export

GPS Export packages a period of raw GPS data for one or more devices into a downloadable archive (GPX / CSV / KML) through an asynchronous job: create the job, track its progress, then download the artifact. Job management requires the gps:export scope (pro tier or above), and job creation consumes the plan's monthly export_job quota (see Rate Limits).

Creating a job

POST /v1/gps/export-jobs — requires the gps:export scope. from / to / format are required, and the requested range can span at most 90 days (UTC calendar days — to at most 90 days after from). A wider range is rejected with 422 invalid_field carrying maxRangeDays: 90 and the offending rangeDays, before any quota is charged — fix the range; retrying does not help. Omitting psns targets every cloud-connected device in the organization.

Whole UTC days, not timestamps

All timestamps in this API are UTC (RFC 3339, Z), and export ranges are UTC calendar days: only the UTC date of from / to is used — the time of day is ignored and the archive covers whole UTC days, so 2026-06-01T18:00:00Z and 2026-06-01T00:00:00Z request the same first day. The timestamps inside the exported files (GPX <time>, CSV datetime) are UTC as well.

curl -X POST "https://openapi.fleeta.io/v1/gps/export-jobs" \
-H "Authorization: Bearer flt_live_..." \
-H "Content-Type: application/json" \
-d '{
"psns": ["7XBPK0BE00000001", "7XBPK0BE00000003"],
"from": "2026-06-01T00:00:00Z",
"to": "2026-07-01T00:00:00Z",
"format": "csv",
"packaging": "perDevice"
}'
{
"data": {
"jobId": "gpsexp_a1b2c3d4e5f6",
"status": "queued",
"estimatedPoints": 480,
"createdAt": "2026-07-16T04:00:00Z"
}
}
  • format: gpx / csv / kml
  • packaging: single (one merged file) or perDevice (one file per device). Omit it and the job follows the web console: perDevice when more than one device is targeted, single for a single device. Set it explicitly whenever you need a specific layout — an explicit value is always used as given.
A merged CSV can outgrow your spreadsheet

packaging: "single" puts every device and every day into one file, and a CSV holds one row per GPS point — there is no row cap. Spreadsheets stop at 1,048,576 rows (Excel, Google Sheets and LibreOffice alike), and Excel will open a larger file with the surplus silently cut off, which reads as "my data disappeared".

Rough size: rows ≈ devices × days × 300 to 1,000. A busy vehicle logs about 1,000 points a day and a fleet averages nearer 300, so:

DevicesDaysRows (≈300/day)Rows (≈1,000/day)
103090,000300,000
7030630,0002,100,000 ⚠
70901,890,000 ⚠6,300,000 ⚠

Use perDevice (the default for multi-device jobs) or split the range into shorter jobs when the estimate approaches the limit. GET /v1/gps/export-jobs/{jobId} reports estimatedPoints, which is the same number of CSV rows.

  • Cloud devices only — GPS export covers cloud-connected dashcams (category: cloud in GET /v1/devices). Naming a Wi-Fi-only dashcam (category: wifi) in psns returns 422 cloud_only with the offending PSNs in psns; a PSN outside your company/group scope returns 422 unknown_psn. Omit psns to export every cloud-connected device in the organization.
  • One job at a time per organization — creating a job while another is still queued / running returns a 409. Two distinct codes can appear here, and they are checked in this order:
    • concurrency_limit_exceeded — your tier's concurrent-job allowance is used up. The problem response carries limit and active. Only enterprise keys have an allowance above 1; on tiers whose allowance is 0, GPS Export is not offered at all and every create returns this code. This check runs before the quota is charged, so a rejected create costs you nothing.
    • export_in_progress — the export backend already has a job running for this organization. The response carries the in-flight job's activeJobId and activeStatus so you can poll or cancel it first.
  • The monthly job count is capped by the export_job quota — exceeding it returns 403 quota_exceeded, which is not retryable — see Rate Limits.

Tracking progress

GET /v1/gps/export-jobs/{jobId} returns the job with a real-time progress percentage. While the job is running, progress advances from 0 up to 95 as chunks are processed (computed live from worker progress); it reads 100 exactly when status is completed.

{
"data": {
"jobId": "gpsexp_a1b2c3d4e5f6",
"status": "running",
"progress": 62,
"estimatedPoints": 480,
"createdAt": "2026-07-16T04:00:00Z"
}
}

The job lifecycle is queued → running (progress 0–95) → completed (progress 100). A job can also end as failed, or canceled via DELETE /v1/gps/export-jobs/{jobId} (canceling an already-completed job returns 409 job_already_completed).

Downloading the result

Once status is completed, the same lookup includes a presigned downloadUrl:

{
"data": {
"jobId": "gpsexp_a1b2c3d4e5f6",
"status": "completed",
"progress": 100,
"estimatedPoints": 480,
"createdAt": "2026-07-16T04:00:00Z",
"completedAt": "2026-07-16T04:00:30Z",
"downloadUrl": "https://…s3….amazonaws.com/gps-export/…/output.zip?X-Amz-Signature=…",
"downloadFileName": "gps-export_20260601_20260701_csv.zip",
"downloadUrlExpiresIn": 3600
}
}
  • downloadUrl is valid for downloadUrlExpiresIn seconds (3600 = 1 hour) from the moment of the response. It is regenerated on every lookup — if a URL expires, simply re-request the job to get a fresh one.
  • The URL is a plain presigned link — download it with any HTTP client, no Authorization header needed (and none should be sent).
  • downloadUrl is omitted when the job produced no data for the requested range — treat it as optional.

What is inside the archive

The download is a zip. Its entries depend on packaging: one export.<ext> for single, or one file per device (camera-<name>-<psn>.<ext>) for perDevice. Every timestamp inside is UTC.

CSV columns

One row per GPS point, in this order. Columns are only ever appended — the existing names and their order are a fixed contract, so a parser that reads by position keeps working.

ColumnMeaningUnit / format
psnDashcam serial number
carNameVehicle name at export time (empty when unnamed)
datetimeTime of the pointRFC 3339 UTC (…Z)
latLatitudedegrees (WGS 84)
lngLongitudedegrees (WGS 84)
drive_noTrip number the point belongs tointeger
headingDirection of traveldegrees, 0 = north
speedKmhGround speedkm/h, one decimal — empty when the device reported no speed (0 means "stationary" and is a real value)

GPX / KML

  • GPX 1.1 — one <trk> per trip, <trkpt> per point with <time> (UTC). Speed rides in the standard Garmin extension (<gpxtpx:TrackPointExtension><gpxtpx:speed>), whose unit is metres per second, not km/h. Points with no reported speed carry no <extensions> block at all.
  • KML 2.2 — one <Placemark> per trip with a <LineString>. KML has no standard place for a per-point scalar, so KML carries no speed. Choose CSV or GPX when you need it.

Integration patterns

Two recommended ways to consume export jobs:

(a) Polling — when you need a progress UI

Poll the job at a modest interval (5 seconds or more) and render progress; download when completed.

async function runExport(bv, body) {
const { data: job } = await bv.telemetry.createExportJob(body);

for (;;) {
await new Promise((r) => setTimeout(r, 5000)); // 5s+ — don't burn rate limit
const { data } = await bv.telemetry.exportJob(job.jobId);
render(data.progress); // 0–95 while running, 100 when done

if (data.status === 'completed') return data.downloadUrl; // presigned, 1h
if (data.status === 'failed' || data.status === 'canceled') {
throw new Error(`export ${data.status}`);
}
}
}

Subscribe to the export_completed webhook and download when the push arrives — no polling loop, no wasted quota. The webhook payload carries its own downloadUrl / downloadFileName / downloadUrlExpiresIn (1 hour), so most receivers can download straight from the delivery. If your queue delays processing past the TTL, re-request GET /v1/gps/export-jobs/{jobId} for a fresh URL.

app.post('/hooks/fleeta', express.raw({ type: 'application/json' }), async (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhook(process.env.WEBHOOK_SECRET, rawBody, req.headers)) {
return res.status(401).end();
}
res.status(200).end(); // ack first, process async

const payload = JSON.parse(rawBody);
if (payload.event === 'export_completed' && payload.data.downloadUrl) {
await downloadTo(payload.data.downloadUrl, payload.data.downloadFileName);
}
});

See the Webhooks guide for the full payload (jobId / format / packaging / cameraCount / rangeStart / rangeEnd / sizeBytes / partial / download fields) and signature verification — verifyWebhook above is the guide's any-match verifier, which keeps working through a secret rotation.

Caveats / best practices

  • Partial results: partial: true (in the webhook payload) means some devices/chunks failed — the artifact covers only part of the requested range. Decide whether to re-run for the missing window.
  • Jobs and their artifacts expire (expiresAt in list responses) — don't treat the job store as long-term storage; move artifacts to your own storage after download.
  • Listing jobs (GET /v1/gps/export-jobs) uses cursor pagination, newest first — filter with status.
  • Range cap: split exports longer than 90 days into consecutive jobs (one at a time per organization — see above). The cap counts UTC calendar days, and the time of day in from / to is ignored.
  • File size: there is no row cap on a merged file. Leave packaging unset (multi-device jobs then split per device) or set it explicitly, and check estimatedPoints against the 1,048,576-row spreadsheet limit before handing a single CSV to a spreadsheet — see the caution above.
  • Everything is UTC: the from / to you send, the from / to / createdAt / completedAt echoed on the job, the webhook's rangeStart / rangeEnd, and every timestamp inside the exported files. Convert to local time on your side if you need it.
  • Export quotas are tier-based (export_job monthly, export_concurrent parallel). Exceeding export_concurrent returns 409 concurrency_limit_exceeded; independently of the quota, an organization runs one job at a time upstream (409 export_in_progress). A 403 quota_exceeded is not solved by retrying — see Rate Limits.
  • Webhooksexport_completed payload and HMAC verification
  • Rate Limitsexport_job / export_concurrent quotas
  • Pagination — cursor iteration for the job list
  • SDKstelemetry.createExportJob() / exportJob() / cancelExportJob()