API reference

Every job takes inputs — an uploaded key or an external http(s) URL — and returns a job id you poll until it lands. Your ffmpeg options pass through verbatim.

Run a job

POST /api/ffmpeg/job/

Queues an ffmpeg operation over one or more inputs (already-uploaded files or external URLs) and produces one or more outputs. Processing runs async on a separate worker — this response only confirms the job was queued, not the final result (see GET /api/ffmpeg/job/{job_id}/ to check on it).

Parameters

global_options optional array of strings
ffmpeg flags applied globally, before the inputs.
inputs required array
Needs at least one item. Each item needs an "input_source" — either the key of a file you already uploaded AND confirmed (see the Files endpoints), or an external http(s) URL. The server auto-detects which one it is (anything that parses as an absolute http/https URL is treated as a URL, everything else as a storage key) — plus an optional "options" with flags for that specific input.
outputs required array
Needs at least one item. Each item needs a "filename" (a simple name, no paths) and an optional "options" with the ffmpeg flags for that output (e.g. -vf, -c:v, -crf).
ffmpeg_version optional string
Which ffmpeg release runs the job: 7.1, 8.1, 9.0. Leave it out and it runs on 7.1, the worker's default. Any other value is rejected with a 400, so nothing is queued and nothing is billed.

Example request

POST /api/ffmpeg/job/
{
  "global_options": [
    "-y"
  ],
  "inputs": [
    {
      "input_source": "https://video-generator-ai.sfo3.cdn.digitaloceanspaces.com/assistant-test-files/video.mp4",
      "options": [
        "-t",
        "30"
      ]
    },
    {
      "input_source": "inputs/42/brand/logo.png",
      "options": [
        "-loop",
        "1"
      ]
    }
  ],
  "outputs": [
    {
      "filename": "output.mp4",
      "options": [
        "-filter_complex",
        "[0:v][1:v]overlay=10:10",
        "-c:v",
        "libx264",
        "-crf",
        "23"
      ]
    },
    {
      "filename": "thumbnail.jpg",
      "options": [
        "-vf",
        "scale=320:-1",
        "-frames:v",
        "1"
      ]
    }
  ]
}

Example response

202 Accepted
{
  "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "pending"
}

Response fields

job_id string
UUID of the newly created job — used to check its status at GET /api/ffmpeg/job/{job_id}/.
status string
the job's initial status. Always "pending" in this response; once you check on it later it can be "processing", "completed" or "failed".

Assistant

Ask what you want to do — "how do I downscale a video from 1080p to 480p" — and you get the endpoint, the full request, and an explanation of every option used.

Get job status

GET /api/ffmpeg/job/{job_id}/

Checks the status of a job created by POST /api/ffmpeg/job/ — poll this until status reaches a terminal value ("success", "failed" or "timeout"); before that it's "pending", "download", "running" or "upload" depending on which stage of the pipeline it's in. A job id that doesn't exist, or belongs to someone else, gets the same 404 either way — never leaks which one it was.

Example response

200 OK
{
  "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "success",
  "created_at": "2026-08-22T10:00:00Z",
  "started_at": "2026-08-22T10:00:05Z",
  "finished_at": "2026-08-22T10:00:42Z",
  "output_bytes": 5242880,
  "duration_seconds": 36.482,
  "billed_seconds": 68.91,
  "outputs": [
    {
      "filename": "output.mp4",
      "size": 5242880,
      "url": "https://s3.example.com/outputs/42/output.mp4?X-Amz-Signature=..."
    }
  ],
  "ffmpeg_version": "7.1"
}

Response fields

job_id string
UUID of the job.
status string
either "pending", "download", "running", "upload" (still going), or "success", "failed", "timeout" (terminal — stop polling once you see one of these).
created_at string
when the job was created.
started_at string | null
when the worker started processing it, or null if it hasn't yet.
finished_at string | null
when the job reached a terminal status, or null if it hasn't yet.
output_bytes integer | null
total bytes across all produced outputs. Null until terminal, and 0 when the job failed before ffmpeg wrote anything.
duration_seconds number | null
wall-clock time the worker spent on the job — download, ffmpeg and upload together. Null until terminal.
billed_seconds number | null
what this job took off your plan's allowance, which is not the same as "duration_seconds": it is the CPU it burned or a floor proportional to the bytes read from your storage, whichever is higher. Null until terminal, and 0 when the failure was on our side.
outputs array
array of produced files — only present when status is "success". Each item has "filename", "size" and a signed "url" to download it.
ffmpeg_version string
the ffmpeg release ("major.minor", e.g. "7.1") that produced the outputs — only present when status is "success", same condition as "outputs". It is what actually ran: the "ffmpeg_version" you asked for, or the default when you asked for none.
detail string
error message — only present when status is "failed" or "timeout".
error_type string
machine-readable error code (e.g. "ffmpeg_run-ffmpeg-execution_error", "download-http-unreachable_url") identifying which stage/cause failed — only present when status is "failed" or "timeout", same condition as "detail".
returncode integer
ffmpeg's process exit code — only present when the job actually ran to completion and failed (absent on "timeout", where it never got that far).

Code example

GET /api/ffmpeg/job/{job_id}/
(no request body — GET request)

Run a job

POST /api/ffprobe/job/

Runs ffprobe over an already-uploaded file or an external URL and returns its metadata (container format, duration, codecs, one entry per stream) — unlike /api/ffmpeg/job/, it doesn't produce any output file, it only inspects the one it's given. Answers in one of two ways, and your client has to handle both: 200 with the result in the body (an ordinary metadata probe, which takes tens of milliseconds), or 202 with a job_id when it's taking longer — then poll GET /api/ffprobe/job/{job_id}/, the same contract as POST /api/ffmpeg/job/. You hit the 202 path when the probe has real work to do: -count_frames over a long video, or an external URL that's slow to download.

Parameters

input_source required string
Either a key from a file already uploaded via the Files endpoints, or an external http(s) URL — same auto-detection as the "inputs" of /api/ffmpeg/job/.
options optional array of strings
Raw ffprobe flag tokens (e.g. -select_streams, -show_entries, -count_frames, -count_packets), in the order you want them on the command line. Flags the server controls internally (-i, -protocol_whitelist, -tls_verify, -max_redirects, -rw_timeout, -print_format/-of, -v/-loglevel) are rejected with a 400. Pass no -show_* flag and the probe runs with -show_format -show_streams (everything ffprobe knows); pass any of them and you get exactly the sections you asked for, so -show_entries stream=width,height returns those two fields and an empty "format".
ffmpeg_version optional string
Which ffmpeg release runs the probe: 7.1, 8.1, 9.0. Leave it out and it runs on 7.1, the worker's default. Any other value is rejected with a 400, so nothing is queued and nothing is billed.

Example request

POST /api/ffprobe/job/
{
  "input_source": "https://video-generator-ai.sfo3.cdn.digitaloceanspaces.com/assistant-test-files/video.mp4",
  "options": [
    "-select_streams",
    "v:0",
    "-show_entries",
    "stream=width,height,r_frame_rate"
  ]
}

Example response

200 OK
{
  "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "success",
  "created_at": "2026-08-21T12:00:00Z",
  "started_at": "2026-08-21T12:00:01Z",
  "finished_at": "2026-08-21T12:00:03Z",
  "duration_seconds": 1.874,
  "billed_seconds": 0.041,
  "format": {},
  "streams": [
    {
      "width": 1280,
      "height": 720,
      "r_frame_rate": "30/1"
    }
  ],
  "ffmpeg_version": "7.1"
}

Response fields

job_id string
UUID of the probe job that was run.
status string
on a 200, either "success", "failed" or "timeout". On a 202 it's still "pending" or "running" — keep polling the GET below until it turns terminal.
created_at string
when the job was created.
started_at string | null
when ffprobe actually started running, or null if it never got to.
finished_at string | null
when the job reached its final status, or null if it never did.
duration_seconds number | null
wall-clock time the worker spent on the probe, download included. Null until terminal.
billed_seconds number | null
what this probe took off your plan's allowance. In practice its CPU alone: a metadata probe bills next to nothing, but -count_frames decodes the whole file and is charged for it. Null until terminal.
format object
raw ffprobe "format" block (container/duration/size/bit_rate/tags) — only present when status is "success", and empty if "options" asked for sections that leave it out.
streams array
raw ffprobe "streams" array (one entry per audio/video/subtitle stream) — only present when status is "success", and empty if "options" asked for sections that leave it out.
ffmpeg_version string
the ffmpeg release ("major.minor", e.g. "7.1") whose ffprobe read the file — only present when status is "success", same condition as "format"/"streams". It is what actually ran: the "ffmpeg_version" you asked for, or the default when you asked for none.
detail string
error message from ffprobe or the worker — only present when status is "failed" or "timeout".
error_type string
machine-readable error code (e.g. "ffprobe_run-ffprobe-execution_error", "download-http-unreachable_url") identifying which stage/cause failed — only present when status is "failed" or "timeout", same condition as "detail".
returncode integer
exit code ffprobe exited with — same condition as "detail", and only when ffprobe actually got to run.

Code example

POST /api/ffprobe/job/
{
  "input_source": "https://video-generator-ai.sfo3.cdn.digitaloceanspaces.com/assistant-test-files/video.mp4",
  "options": [
    "-select_streams",
    "v:0",
    "-show_entries",
    "stream=width,height,r_frame_rate"
  ]
}

Original input

Get job status

GET /api/ffprobe/job/{job_id}/

Checks the status of a probe that came back as 202 — poll this until status reaches a terminal value ("success", "failed" or "timeout"). A probe that answered 200 is already done and needs no polling. A job id that doesn't exist, or belongs to someone else, gets the same 404 either way — never leaks which one it was.

Example response

200 OK
{
  "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "success",
  "created_at": "2026-08-21T12:00:00Z",
  "started_at": "2026-08-21T12:00:01Z",
  "finished_at": "2026-08-21T12:01:27Z",
  "duration_seconds": 86.231,
  "billed_seconds": 85.004,
  "format": {
    "filename": "pipe:0",
    "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
    "duration": "600.000000"
  },
  "streams": [
    {
      "index": 0,
      "codec_type": "video",
      "nb_read_frames": "36000"
    }
  ],
  "ffmpeg_version": "7.1"
}

Response fields

job_id string
UUID of the probe job.
status string
"pending" or "running" while it's still going, or "success", "failed", "timeout" (terminal — stop polling once you see one of these).
created_at string
when the job was created.
started_at string | null
when ffprobe actually started running, or null if it hasn't yet.
finished_at string | null
when the job reached a terminal status, or null if it hasn't yet.
duration_seconds number | null
wall-clock time the worker spent on the probe, download included. Null until terminal.
billed_seconds number | null
what this probe took off your plan's allowance. In practice its CPU alone: a metadata probe bills next to nothing, but -count_frames decodes the whole file and is charged for it. Null until terminal.
format object
raw ffprobe "format" block — only present when status is "success".
streams array
raw ffprobe "streams" array — only present when status is "success".
ffmpeg_version string
the ffmpeg release ("major.minor", e.g. "7.1") whose ffprobe read the file — only present when status is "success", same condition as "format"/"streams". It is what actually ran: the "ffmpeg_version" you asked for, or the default when you asked for none.
detail string
error message — only present when status is "failed" or "timeout".
error_type string
machine-readable error code identifying which stage/cause failed — only present when status is "failed" or "timeout".
returncode integer
exit code ffprobe exited with — same condition as "detail", and only when ffprobe actually got to run.

Code example

GET /api/ffprobe/job/{job_id}/
(no request body — GET request)

Upload a file

POST /api/files/upload/sign/

Uploading takes three calls, and the file itself never passes through this API: you ask for a signed URL here, PUT the bytes straight to the bucket, and then confirm. That is what lets you upload a file of several GB — nothing has to fit inside one request to us. Only key is required; if you also know the exact size, declaring it gets it signed into the URL so the storage refuses anything else before a byte is kept. Either way the confirm step measures the file and rejects one over your plan's limit. Try it below runs the three calls for real, with a fixed sample file: the bytes leave your browser straight for the bucket, exactly as they would from your own client.

Parameters

key required string
Where the file goes inside your own folder: a relative path and a file name, like "promos/2026/video.mp4". The server puts it under "inputs/<your id>/" and hands you back the full key, so there is no way to write anywhere else. Letters, digits, ".", "_", "-" and "/" only — no "..", no leading "/". Uploading to a key you already used replaces that file.
size optional integer
Exact size of the file in bytes, when you know it. Checked against your plan before signing and then pinned into the signature, so sending more OR fewer bytes than this fails at the bucket. Leave it out and the upload is unconstrained until you confirm.

Example request

POST /api/files/upload/sign/
{
  "key": "demo/quickffmpeg-sample.mp4",
  "size": 12345678
}

Example response

200 OK
{
  "key": "inputs/42/demo/quickffmpeg-sample.mp4",
  "method": "PUT",
  "url": "https://s3.us-southeast-1.idrivee2.com/quickffmpeg-files/inputs/42/demo/quickffmpeg-sample.mp4?X-Amz-Signature=...",
  "headers": {
    "Content-Length": "12345678"
  },
  "expires_in": 900,
  "max_bytes": 157286400
}

Response fields

key string
storage key reserved for this upload. Pass it to /api/files/upload/confirm/ once the bytes are up, and then as input_source to /api/ffmpeg/job/ or /api/ffprobe/job/.
method string
HTTP verb to use against "url" — always "PUT" today. Read it instead of hardcoding it.
url string
signed URL of the bucket. Send the raw bytes of the file as the body — NOT multipart/form-data, a client that wraps the file in a form gets a parse error from the storage — and with no Authorization header of ours.
headers object
headers the upload must carry exactly as given. Empty unless you declared a size, in which case it carries the "Content-Length" that is part of the signature.
expires_in integer
seconds the signed URL stays valid. After that, sign again: the key is the same one, so nothing else about your upload changes.
max_bytes integer
your plan's budget for the inputs of ONE job, combined, in bytes — which is also why no single file may exceed it. Going over it fails at the confirm step (or at the bucket, if you declared a size); two files that each confirm fine can still be too much for one job together.

Code example

POST /api/files/upload/sign/
{
  "key": "demo/quickffmpeg-sample.mp4",
  "size": 12345678
}

Runs the three calls for real, always with the same sample file and the same key.

Confirm upload

POST /api/files/upload/confirm/

Registers a file you already uploaded to the bucket, which is what makes its key usable as an input_source. It reads the real size from storage — a file that never arrived gets a 404, and one uploaded but never confirmed is reaped by a daily cleanup, so confirm it in the same run instead of coming back for it tomorrow. Confirming the same key twice is safe: the second time answers 200 instead of 201.

Parameters

key required string
The key POST /api/files/upload/sign/ handed back. A key that was not issued to you is rejected without saying whether it exists.

Example request

POST /api/files/upload/confirm/
{
  "key": "inputs/42/demo/quickffmpeg-sample.mp4"
}

Example response

201 Created
{
  "key": "inputs/42/demo/quickffmpeg-sample.mp4",
  "size": 12345678,
  "url": "https://s3.example.com/inputs/42/demo/quickffmpeg-sample.mp4?X-Amz-Signature=..."
}

Response fields

key string
the same key, now registered — pass it as input_source to /api/ffmpeg/job/ or /api/ffprobe/job/.
size integer
size in bytes as read from storage, not as you declared it. This is what counts against a job's total input budget.
url string
signed, temporary URL to download/preview the file directly.

Code example

POST /api/files/upload/confirm/
{
  "key": "inputs/42/demo/quickffmpeg-sample.mp4"
}

Read a file

GET /api/files/read/{key}/

Returns a fresh signed, temporary URL to download a file you already own. The URL that POST /api/files/upload/confirm/ hands back expires after 15 minutes — this is how you get another one without re-uploading. A key that doesn't exist, or belongs to someone else, gets the same 404 either way — never leaks which one it was.

Parameters

key required string
The storage key returned by /api/files/upload/confirm/ (or the key of a job output). Goes in the path, not in a body — it contains slashes ("inputs/42/…"), and the route takes all of them as part of the key.

Example response

200 OK
{
  "key": "inputs/42/demo/quickffmpeg-sample.mp4",
  "url": "https://s3.example.com/inputs/42/demo/quickffmpeg-sample.mp4?X-Amz-Signature=..."
}

Response fields

key string
the same key you asked for, echoed back.
url string
signed, temporary URL to download/preview the file directly.

Code example

GET /api/files/read/{key}/
(no request body — GET request)

Ask me for anything

QuickFFmpeg is early and plenty is still missing. Tell me what you need and it goes straight to the top of my list.

This arrives anonymously. Log in first if you'd like an answer.