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.

The range must also fall inside your plan's GPS retention window — the last 30 days on Standard, 90 days on Pro and 120 days on Enterprise (see Data retention by plan). A from earlier than the window starts the export at the first retained day, and GET /v1/gps/export-jobs/{jobId} shows that actual from. A range that ends before the window has nothing left to export, so it is rejected with 422 invalid_field carrying retentionDays and earliestFrom (the earliest from your plan accepts) instead of producing an empty file.

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.
  • Webhooks — export_completed payload and HMAC verification
  • Rate Limits — export_job / export_concurrent quotas
  • Pagination — cursor iteration for the job list
  • SDKs — telemetry.createExportJob() / exportJob() / cancelExportJob()