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.
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".
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.
recordedAtLocal has no timezoneThe 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.
3. Get a playable link
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.
| Field | Meaning |
|---|---|
url | Signed link that plays or downloads the clip. Use it exactly as returned — it already carries the filesize parameter the media server needs. |
expiresAt | When the signature stops working, read from the link itself. null means the link carries no signed expiry — the deadline is unknown, not unlimited. |
jobId | The recall job created behind this link. Use it with GET /v1/media/recall-jobs/{jobId} for progress and DELETE …/{jobId} to stop the upload. |
sizeBytes | Size 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. |
receivedBytes | How 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. |
quality | The stream the link plays. |
GET has side effectsEvery 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:
| Status | Meaning |
|---|---|
ready | The device accepted the transfer. downloadUrl is live — the upload is either in progress or complete. |
canceled | You 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}/videoPOST /v1/media/recall-jobsGET /v1/devices/{psn}/sd-filesGET /v1/devices/{psn}/sd-files/{filename}/metadataDELETE /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.
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:
| Quality | Typical size (1-minute clip) | Typical duration |
|---|---|---|
sub | 5–10 MB | a few seconds |
main | 300 MB or more | several 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 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 allowance | Every 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 lifetime | About 1 hour (expiresAt). Expired? Ask for the link again — within 48 hours that usually costs no new upload. |
| Uploaded file retention | 1 day on the media server, then deleted. |
| Job history | Kept for 7 days via GET /v1/media/recall-jobs. |
| Concurrency | One transfer per device, enforced by the dashcam itself — everything else answers 409 device_busy while a transfer runs. |
| Camera seat | The 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
| Status | Meaning |
|---|---|
409 device_busy | The dashcam is mid-transfer (one at a time). Wait Retry-After seconds and retry the same request — see One device, one transfer. |
409 device_offline | The 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_absent | No card in the device. |
404 file_not_found | The clip is gone — the card recycled it, or it was deleted. |
422 invalid_field | filename 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_parameter | quality is not main or sub (playback call; the job call answers 422 invalid_field). |
403 quota_exceeded | The 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_exceeded | Declared 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_exceeded | The 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_timeout | The device stopped responding mid-request. Retry; if it repeats, the vehicle likely lost connectivity. |
Scopes
| Scope | Grants |
|---|---|
media:read | List recordings, read metadata, view recall jobs |
media:recall | Get a playable link, create and cancel recall jobs |
media:write | Delete 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.
Related links
- Vehicles & Dashcams — the device record, camera seats and what
PUTcan change - Authentication — scopes by tier
- Errors — RFC 9457 problem+json,
device_busy - API Reference — full schemas and Try-it