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.
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/kmlpackaging:single(one merged file) orperDevice(one file per device). Omit it and the job follows the web console:perDevicewhen more than one device is targeted,singlefor a single device. Set it explicitly whenever you need a specific layout — an explicit value is always used as given.
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:
| Devices | Days | Rows (≈300/day) | Rows (≈1,000/day) |
|---|---|---|---|
| 10 | 30 | 90,000 | 300,000 |
| 70 | 30 | 630,000 | 2,100,000 ⚠ |
| 70 | 90 | 1,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: cloudinGET /v1/devices). Naming a Wi-Fi-only dashcam (category: wifi) inpsnsreturns422 cloud_onlywith the offending PSNs inpsns; a PSN outside your company/group scope returns422 unknown_psn. Omitpsnsto export every cloud-connected device in the organization. - One job at a time per organization — creating a job while another is
still
queued/runningreturns a409. 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 carrieslimitandactive. Only enterprise keys have an allowance above 1; on tiers whose allowance is0, 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'sactiveJobIdandactiveStatusso you can poll or cancel it first.
- The monthly job count is capped by the
export_jobquota — exceeding it returns403 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
}
}
downloadUrlis valid fordownloadUrlExpiresInseconds (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
Authorizationheader needed (and none should be sent). downloadUrlis 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.
| Column | Meaning | Unit / format |
|---|---|---|
psn | Dashcam serial number | — |
carName | Vehicle name at export time (empty when unnamed) | — |
datetime | Time of the point | RFC 3339 UTC (…Z) |
lat | Latitude | degrees (WGS 84) |
lng | Longitude | degrees (WGS 84) |
drive_no | Trip number the point belongs to | integer |
heading | Direction of travel | degrees, 0 = north |
speedKmh | Ground speed | km/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}`);
}
}
}
(b) export_completed webhook — recommended for server integrations
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 (
expiresAtin 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 withstatus. - 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/tois ignored. - File size: there is no row cap on a merged file. Leave
packagingunset (multi-device jobs then split per device) or set it explicitly, and checkestimatedPointsagainst the 1,048,576-row spreadsheet limit before handing asingleCSV to a spreadsheet — see the caution above. - Everything is UTC: the
from/toyou send, thefrom/to/createdAt/completedAtechoed on the job, the webhook'srangeStart/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_jobmonthly,export_concurrentparallel). Exceedingexport_concurrentreturns409 concurrency_limit_exceeded; independently of the quota, an organization runs one job at a time upstream (409 export_in_progress). A403 quota_exceededis not solved by retrying — see Rate Limits.
Related links
- Webhooks —
export_completedpayload and HMAC verification - Rate Limits —
export_job/export_concurrentquotas - Pagination — cursor iteration for the job list
- SDKs —
telemetry.createExportJob()/exportJob()/cancelExportJob()