Skip to main content

SD-card recordings — play and download

A dashcam keeps every clip it records on its own memory card. Only a small slice of that — safety events — is uploaded to the cloud automatically. The media endpoints let you reach the rest: see what is on the card, look at a clip before committing to it, and get a link that plays or downloads it. One call returns the link; the dashcam uploads the clip over LTE behind it, and the link streams while the upload is still running.

Playing a clip costs the vehicle mobile data

The link is not free. The dashcam uploads the file over its own LTE connection, so every recording you play or download consumes the vehicle's data plan — a full-resolution minute of footage is typically 300 MB or more.

Request only the clips you actually need. The playback endpoint defaults to quality=sub — the low-resolution stream is about 4 % of the size (measured across recent recalls) and is usually enough to see what happened. Ask for main only when you need the full-resolution file. Never loop a request across a whole fleet "just in case".

Cloud-connected dashcams only

Every route on this page talks to the dashcam over its cloud connection. A Wi-Fi-only dashcam (category: wifi in GET /v1/devices) has no such connection, so its PSN answers 422 cloud_only here — filter the device list with category=cloud first.

The three steps

1. GET /v1/devices/{psn}/sd-files what is on the card?
2. GET /v1/devices/{psn}/sd-files/{filename}/metadata is this the clip I want?
3. GET /v1/devices/{psn}/sd-files/{filename}/video give me a link that plays it

Steps 1 and 2 ask the device directly and cost no meaningful data. Only step 3 transfers video. Step 3 has a second form, POST /v1/media/recall-jobs, which returns the same link wrapped in a recall job — the resource you use to watch the upload and cancel it. The playback call creates that job for you and hands back its jobId, so you never need both.

1. See what is on the card

curl "https://openapi.fleeta.io/v1/devices/7XBPK0BE00000001/sd-files" \
-H "Authorization: Bearer flt_live_..."
{
"data": [
{
"filename": "20260727_131445_NF.mp4",
"recordedAtLocal": "2026-07-27T13:14:45",
"type": "normal",
"typeCode": "N",
"direction": "front",
"directionCode": "F",
"stream": "main",
"mainSizeBytes": 322497634,
"subSizeBytes": 5550677
}
],
"pagination": { "page": 1, "perPage": 50, "total": 1, "totalPages": 1 }
}

The dashcam names files as YYYYMMDD_HHMMSS_XY.mp4 — in 20260727_131445_NF, N is the recording type (normal) and F the camera (front). You do not have to decode that yourself: the API does it for you and lets you filter on the result (type, direction, stream, from, to), so ?type=driving_impact&direction=front narrows the list to front-camera driving-impact clips. The two letters are also returned raw as typeCode / directionCode.

type uses the same words as GET /v1/events for every event the dashcam recorded — driving_impact, parking_impact, harsh_braking, harsh_acceleration, sharp_turn, overspeed, manual, the driver-monitoring types and the geofence_* types — so a clip on the card and its event carry one name, with no lookup table in between. Only two values have no event counterpart: normal (continuous driving) and parking. manual is not one of them — a recording the driver started deliberately is published as an event too. A category letter we do not recognize is returned as unknown with the letter in typeCode — never dropped from the list — and a type, direction or stream outside the enum answers 400 with the accepted values in allowed, rather than an empty list you might read as "no such footage". That is also how the retired names event and impact are handled: they answer 400 with driving_impact and parking_impact among the accepted values.

Why recordedAtLocal has no timezone

The device names files using its own local clock and never reports which offset that was. Rather than guess — and be wrong for a vehicle that crossed a border or moved between DST periods — we hand you the wall-clock time exactly as the device recorded it. If you need an absolute instant, pair it with the GPS track from the metadata endpoint, which does carry UTC timestamps.

Note mainSizeBytes vs subSizeBytes — that difference is what you will pay for in mobile data at step 3.

2. Look before you pull

curl "https://openapi.fleeta.io/v1/devices/7XBPK0BE00000001/sd-files/20260727_131445_NF.mp4/metadata?include=thumbnail,gps" \
-H "Authorization: Bearer flt_live_..."

Returns a JPEG thumbnail (base64), the GPS track recorded during the clip (coordinates, speed in km/h, heading, UTC timestamps), and G-sensor samples. Use include to fetch only what you need — a thumbnail alone comes back much faster than all three.

This is usually enough to confirm you have the right clip before spending the vehicle's data on the video itself. It also confirms the clip is still there: a file the dashcam has already overwritten answers 404 file_not_found, so a 200 here means the recording really is on the card.

curl "https://openapi.fleeta.io/v1/devices/7XBPK0BE00000001/sd-files/20260727_131445_NF.mp4/video?quality=sub" \
-H "Authorization: Bearer flt_live_..."
{
"data": {
"url": "https://pittasoft-media.blackvuecloud.com/uploadFiles/s/1785162000/AbC.../7XBPK0BE00000001/20260727_131445_NFS.mp4?filesize=5550677",
"expiresAt": "2026-07-27T14:20:00.000Z",
"jobId": "mrcl_a1b2c3d4e5f6",
"sizeBytes": 5550677,
"receivedBytes": 1048576,
"quality": "sub"
}
}

Hand url to a video player or fetch it — it is already complete and signed. The response is 200 as soon as the dashcam has accepted the upload; you do not wait for the transfer to finish. The device uploads and your player downloads at the same time, so playback runs at the dashcam's upload speed.

FieldMeaning
urlSigned link that plays or downloads the clip. Use it exactly as returned — it already carries the filesize parameter the media server needs.
expiresAtWhen the signature stops working, read from the link itself. null means the link carries no signed expiry — the deadline is unknown, not unlimited.
jobIdThe recall job created behind this link. Use it with GET /v1/media/recall-jobs/{jobId} for progress and DELETE …/{jobId} to stop the upload.
sizeBytesSize of the requested stream (main or sub) as reported by the device — for quality=sub this is the sub-stream size, not the full recording. null when the device did not report it.
receivedBytesHow much the media server has received from the dashcam so far. Best-effort: null when the server could not be probed in time (not an error), and always null on a sandbox key.
qualityThe stream the link plays.
This GET has side effects

Every call asks the dashcam to upload the clip over LTE and counts toward the plan's monthly transfer_bytes allowance by the file's size — exactly like POST /v1/media/recall-jobs. The allowance is checked before the dashcam is contacted: when what remains cannot cover the file, the call answers 403 quota_exceeded and no job is created. Request the link once and reuse it until expiresAt; do not call it on every page render or in a polling loop. Requests that are answered from the media server's existing copy (below) count as well.

Playing the same clip twice

The uploaded file stays on the media server for about a day. If you ask for the same file (same quality) again within 48 hours and the server still holds a complete copy, the API hands you a fresh link without a new upload — the dashcam is still contacted, so it must be online, but it sends nothing and no mobile data is spent. This is automatic: the API remembers the size the device reported the first time and presents it to the media server, which is how the server knows its copy is complete. You still get a new jobId, and the request still counts toward the transfer_bytes allowance — the file reaches you either way.

filename is always the main recording name

Pass filename exactly as the SD file list returns it — 20260727_131445_NF.mp4. The sub-stream is selected with quality=sub, not by editing the name: a filename ending in S.mp4 (the dashcam's own name for the sub-stream file) is rejected with 422 invalid_field, on both the playback call and POST /v1/media/recall-jobs.

The job form: POST /v1/media/recall-jobs

The same transfer can be started as a job. The body takes psn, filename and quality — here the default is main, because a job is usually created to download the full file:

curl -X POST "https://openapi.fleeta.io/v1/media/recall-jobs" \
-H "Authorization: Bearer flt_live_..." \
-H "Content-Type: application/json" \
-d '{"psn":"7XBPK0BE00000001","filename":"20260727_131445_NF.mp4","quality":"sub"}'
{
"data": {
"jobId": "mrcl_a1b2c3d4e5f6",
"status": "ready",
"downloadUrl": "https://pittasoft-media.blackvuecloud.com/uploadFiles/s/1785145232/AbC.../7XBPK0BE00000001/20260727_131445_NF.mp4?filesize=5550677",
"sizeBytes": 5550677,
"expiresAt": "2026-07-29T04:20:00Z"
}
}

A successful POST returns 201 Created — and created means the transfer has actually started. There is no pending state: a job only exists once the dashcam has accepted the upload, so the very first response is already ready and carries a working downloadUrl — the same value the playback call returns as url. If the device could not start the upload, you get an error instead of a job (most commonly 409 device_busy, below).

Both forms run the same code path: same validation, same device contact, same job record, same audit entry, same errors. Pick the playback call when you want a link, the job call when your integration is built around job resources (it mirrors POST /v1/gps/export-jobs).

Job lifecycle

A recall job has exactly two states:

StatusMeaning
readyThe device accepted the transfer. downloadUrl is live — the upload is either in progress or complete.
canceledYou canceled the job (see Cancelling a recall).

There is no queued and no failed: a transfer that cannot start is reported as an error response, never stored as a job.

One device, one transfer

A dashcam handles one transfer at a time. While it is mid-transfer — for your recall, someone else's, or an operation started from the web console — every endpoint that has to talk to the device answers 409 device_busy:

  • GET /v1/devices/{psn}/sd-files/{filename}/video
  • POST /v1/media/recall-jobs
  • GET /v1/devices/{psn}/sd-files
  • GET /v1/devices/{psn}/sd-files/{filename}/metadata
  • DELETE /v1/devices/{psn}/sd-files/{filename}
  • POST /v1/devices/{psn}/reboot
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Retry-After: 5
{
"type": "https://developers.fleeta.io/errors/device_busy",
"title": "Conflict",
"status": 409,
"code": "device_busy",
"detail": "The dashcam handles one transfer at a time and is currently busy with another operation. Retry in a few seconds.",
"requestId": "4f0a5b62-9c81-4a3e-b7d2-6e1f8c0a5d34",
"psn": "7XBPK0BE00000001"
}

The response carries a Retry-After header (seconds). Busy is a normal, temporary condition — retry the same request after that delay. Retrying sooner does not queue anything on the device; it just returns another 409.

Retrying on busy

// Retry a request while the device is busy, honouring Retry-After,
// up to an overall deadline.
async function withBusyRetry(doRequest, { deadlineMs = 60_000 } = {}) {
const deadline = Date.now() + deadlineMs;
for (;;) {
const res = await doRequest();
if (res.status !== 409) return res;
const problem = await res.clone().json();
if (problem.code !== 'device_busy') return res; // other 409s are not retryable
const waitMs = (Number(res.headers.get('Retry-After')) || 5) * 1000;
if (Date.now() + waitMs > deadline) return res; // give up — surface the 409
await new Promise((r) => setTimeout(r, waitMs));
}
}

const psn = '7XBPK0BE00000001';
const filename = '20260727_131445_NF.mp4';
const res = await withBusyRetry(() =>
fetch(
`https://openapi.fleeta.io/v1/devices/${psn}/sd-files/${encodeURIComponent(filename)}/video?quality=sub`,
{ headers: { Authorization: `Bearer ${API_KEY}` } }
)
);
const { data } = await res.json();
videoElement.src = data.url;

Pick the deadline to match what is holding the device: a sub transfer clears in seconds, but a full-resolution main transfer legitimately keeps the device busy for several minutes. If you cannot wait, cancel the job that owns the device instead of hammering the endpoint.

The official SDK can perform this retry loop for you — see SDKs.

If busy never ends

A device that answers device_busy for well past the expected transfer time (many minutes with nothing downloading) is likely wedged mid-transfer. Recover it with POST /v1/devices/{psn}/reboot — the device restarts, drops the stuck transfer, and is briefly offline before coming back idle. Reboot requires the devices:write scope.

How long will it take?

The transfer runs over the dashcam's own LTE uplink, so duration is set by file size, not by your connection:

QualityTypical size (1-minute clip)Typical duration
sub5–10 MBa few seconds
main300 MB or moreseveral minutes over LTE

Because you download while the device uploads, "duration" is both how long your download takes and how long the device stays busy for everything else.

Watching progress

Every link comes with a jobId. GET /v1/media/recall-jobs/{jobId} is a pure read — it never re-issues commands to the device. While the upload is running, the response may include receivedBytes: how much of the file the media server has received from the dashcam so far.

curl "https://openapi.fleeta.io/v1/media/recall-jobs/mrcl_a1b2c3d4e5f6" \
-H "Authorization: Bearer flt_live_..."
{
"data": {
"jobId": "mrcl_a1b2c3d4e5f6",
"status": "ready",
"downloadUrl": "https://pittasoft-media.blackvuecloud.com/uploadFiles/s/1785145232/AbC.../7XBPK0BE00000001/20260727_131445_NF.mp4?filesize=322497634",
"sizeBytes": 322497634,
"receivedBytes": 88104960,
"expiresAt": "2026-07-27T14:20:00Z"
}
}

Compare receivedBytes against sizeBytes for a progress ratio. receivedBytes is best-effort: it comes from a quick probe of the media server, and when the probe does not answer in time the field is simply absent. A missing receivedBytes is not an error and says nothing about the transfer itself — the downloadUrl keeps working either way.

GET /v1/media/recall-jobs lists the jobs of the last 7 days — those created by the playback call included.

Cancelling a recall

curl -X DELETE "https://openapi.fleeta.io/v1/media/recall-jobs/mrcl_a1b2c3d4e5f6" \
-H "Authorization: Bearer flt_live_..."

Cancelling means stop the in-progress upload. The API tells the dashcam to abort the transfer — freeing the device for other operations and sparing the vehicle's remaining mobile data — and marks the job canceled. Use the jobId from the playback response or from the job you created.

  • The device-side abort is best-effort: if the device is unreachable or the upload already finished, the job is still marked canceled.
  • After a cancel, do not rely on the link — a partially uploaded file is not guaranteed to remain playable.
  • Cancelling a job whose upload already completed only marks the record; it does not delete anything from the SD card.
  • Cancelling an already-canceled job is a no-op — the job comes back unchanged, and nothing is sent to the device.
The stop signal is device-wide

The dashcam handles one transfer at a time, and the cancel signal tells the device to stop whatever it is uploading at that moment — it is not scoped to this job's file. A job also stays ready after its upload completes (there is no separate "completed" state). So cancel a job only when you mean to stop its transfer: deleting days-old ready jobs as list housekeeping can abort an unrelated transfer — another recall, or a download started from the web console — that happens to be running on the same device right then.

Limits and expiry

Transfer allowanceEvery recall counts toward the plan's monthly transfer_bytes allowance by the size of the file it moves (sizeBytes; a flat 8 MB sub / 150 MB main estimate when the dashcam does not report it). The allowance is checked before the dashcam is contacted — when what remains cannot cover the file, the call answers 403 quota_exceeded and no job is created; GET /v1/usage shows what is left. Per camera and month it is 5.6 GB on Standard, 10 GB on Pro and 18 GB on Enterprise — roughly 700 sub recordings or 35 minutes of full-quality main footage on Standard, about twice that on Pro and three times on Enterprise. Recall volume is also bounded by the media:recall scope (Pro and above), the camera-seat check (403 camera_limit_exceeded), one transfer per dashcam at a time, and the vehicle's own data plan. Each recall still costs the vehicle real mobile data, so request only the clips you need and prefer quality=sub unless you need full resolution. See Rate Limits › Volume quotas.
Link lifetimeAbout 1 hour (expiresAt). Expired? Ask for the link again — within 48 hours that usually costs no new upload.
Uploaded file retention1 day on the media server, then deleted.
Job historyKept for 7 days via GET /v1/media/recall-jobs.
ConcurrencyOne transfer per device, enforced by the dashcam itself — everything else answers 409 device_busy while a transfer runs.
Camera seatThe dashcam must be within the subscription's camera count (entitlement: covered on GET /v1/devices). An over-limit dashcam answers 403 camera_limit_exceeded on all four SD-card routes and on recall creation — before any allowance is spent. See Rate Limits › Camera seats.

Deleting a recording

curl -X DELETE "https://openapi.fleeta.io/v1/devices/7XBPK0BE00000001/sd-files/20260727_131445_NF.mp4" \
-H "Authorization: Bearer flt_live_..."

Removes that one clip from the card. This cannot be undone — if the clip was never uploaded, no copy exists anywhere. It only ever removes the file you name; it never formats the card.

You rarely need this. The dashcam already recycles its oldest recordings when the card fills up, so deleting is for reclaiming space deliberately, not for routine housekeeping.

Common responses

StatusMeaning
409 device_busyThe dashcam is mid-transfer (one at a time). Wait Retry-After seconds and retry the same request — see One device, one transfer.
409 device_offlineThe dashcam is not connected. Recordings can only be read while it is online — even when the media server still holds a copy.
409 sd_card_absentNo card in the device.
404 file_not_foundThe clip is gone — the card recycled it, or it was deleted.
422 invalid_fieldfilename is not a main recording name from the SD file list (for example it ends in S.mp4). Select the sub-stream with quality=sub.
400 invalid_parameterquality is not main or sub (playback call; the job call answers 422 invalid_field).
403 quota_exceededThe remaining monthly transfer_bytes allowance cannot cover the file. Checked before the dashcam is contacted, so no job is created and no mobile data is spent; limit and used are bytes and detail states the limit in GB. Wait for the monthly reset, prefer quality=sub, or move up a plan — see Rate Limits › Volume quotas.
403 recall_limit_exceededDeclared because the upstream token service can still return it, but the subscription's VOD-download allowance is not applied to API-key calls today — you are unlikely to see this code.
403 camera_limit_exceededThe dashcam is registered beyond the subscription's camera count (entitlement: over_limit) — every SD-card route and recall creation answer this. Raise the camera count in the Fleeta web viewer (Account › Subscription); the same key is unlocked within 5 minutes, no reissue. See Rate Limits › Camera seats.
504 command_timeoutThe device stopped responding mid-request. Retry; if it repeats, the vehicle likely lost connectivity.

Scopes

ScopeGrants
media:readList recordings, read metadata, view recall jobs
media:recallGet a playable link, create and cancel recall jobs
media:writeDelete a recording from the card

media:read and media:recall require the pro tier or above — free, starter, and standard plans carry no media scopes. media:write (permanent deletion) is enterprise only. See Authentication.