Introduction
Very Good FFmpeg is a REST API that runs your exact FFmpeg commands on dedicated cloud hardware and bills you per gigabyte processed.
The API is built around a single resource:
- Jobs describe one FFmpeg invocation — input URLs, output filenames, and the command string.
- Jobs run asynchronously by default. You receive a webhook on completion or poll
GET /api/jobs/{id}until the status terminal-flips. - For short jobs you can pass
?wait=trueto block on the response.
https://verygoodffmpeg.com/api.Quickstart
1. Get an API key
Sign up at verygoodffmpeg.com/sign-up and create a key from the dashboard.
2. Submit a transcode
curl -X POST https://verygoodffmpeg.com/api/ffmpeg \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_files": {
"input": "https://your-bucket.s3.amazonaws.com/input.mp4"
},
"output_files": ["output.mp4"],
"ffmpeg_command": "-i {{input}} -c:v libx264 -crf 23 {{output.mp4}}",
"webhook_url": "https://your-app.example.com/hooks/ffmpeg"
}'3. Read the response
{
"data": {
"id": "8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c",
"status": "queued",
"queued_at": "2026-05-06T18:42:01.114Z",
"started_at": null,
"finished_at": null,
"created_at": "2026-05-06T18:42:01.103Z",
"updated_at": "2026-05-06T18:42:01.103Z",
"error_message": "",
"ffmpeg_command": "-i {{input}} -c:v libx264 -crf 23 {{output.mp4}}",
"input_files": {
"input": "https://your-bucket.s3.amazonaws.com/input.mp4"
},
"output_files": {},
"webhook_url": "https://your-app.example.com/hooks/ffmpeg",
"total_input_bytes": null,
"total_output_bytes": null
}
}The job starts in queued, transitions through running, and ends in succeeded, failed, or cancelled. Use the returned id to poll, or supply a webhook_url to be notified on completion.
Authentication
All requests must include a Bearer token in the Authorization header.
Authorization: Bearer $API_KEY- Keys are scoped to a single organisation.
- You can issue multiple keys per organisation — useful for separating staging and production, or per-service auditing.
- Revoke a key from the dashboard; revocation is effective within seconds across all regions.
Reference
Four endpoints cover the full job lifecycle. All responses wrap the resource in a top-level data field.
| Name | Type | Description |
|---|---|---|
| POST /api/ffmpeg | Submit | Create and queue an FFmpeg job. Optionally wait for completion. |
| GET /api/jobs | List | List jobs for the authenticated organisation, newest first. |
| GET /api/jobs/{id} | Read | Fetch the current state of a single job. |
| POST /api/jobs/{id}/cancel | Cancel | Request cancellation. Best-effort for running jobs. |
POST /api/ffmpeg
Submit a new FFmpeg job. The default is asynchronous — pass ?wait=true to block until the job finishes.
Request body
| Name | Type | Description |
|---|---|---|
| input_files | object<string,url> | Required. Map of template names to publicly readable URLs. Reference each by name in ffmpeg_command as {{name}}. |
| output_files | string[] | Required. Filenames the command will produce. Reference each as {{<filename>}}. The API rejects jobs that don’t emit at least one declared output. |
| ffmpeg_command | string | Required. FFmpeg arguments only — omit the leading ffmpeg binary name. Standard flags, filter graphs, and complex pipelines all work. |
| webhook_url | url | Optional. HTTPS URL we POST to with the terminal job payload. |
| machine | "cpu" | "nvidia" | Optional. Hardware target. Defaults to cpu. Set to nvidia to route the job to a GPU worker. Read Hardware acceleration before flipping this — GPU jobs usually slower unless your command actually uses GPU codecs and filters. |
Query parameters
| Name | Type | Description |
|---|---|---|
| wait | boolean | Optional. When true, the request blocks for up to 60 seconds waiting for the job to finish, then returns the latest state. Default is false. |
Example request
curl -X POST "https://verygoodffmpeg.com/api/ffmpeg?wait=false" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_files": {
"input": "https://example.com/source.mov"
},
"output_files": ["preview.mp4", "thumb.jpg"],
"ffmpeg_command": "-i {{input}} -t 30 -c:v libx264 {{preview.mp4}} -ss 5 -frames:v 1 {{thumb.jpg}}"
}'Example response
{
"data": {
"id": "8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c",
"status": "queued",
"queued_at": "2026-05-06T18:42:01.114Z",
"started_at": null,
"finished_at": null,
"created_at": "2026-05-06T18:42:01.103Z",
"updated_at": "2026-05-06T18:42:01.103Z",
"error_message": "",
"ffmpeg_command": "-i {{input}} -t 30 -c:v libx264 {{preview.mp4}} -ss 5 -frames:v 1 {{thumb.jpg}}",
"input_files": { "input": "https://example.com/source.mov" },
"output_files": {},
"webhook_url": null,
"total_input_bytes": null,
"total_output_bytes": null
}
}GET /api/jobs/{id}
Fetch the latest state of a single job. The response shape matches the create endpoint.
Path parameters
| Name | Type | Description |
|---|---|---|
| id | string | Job identifier returned at submission. |
Example request
curl https://verygoodffmpeg.com/api/jobs/8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c \
-H "Authorization: Bearer $API_KEY"Example response
{
"data": {
"id": "8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c",
"status": "succeeded",
"queued_at": "2026-05-06T18:42:01.114Z",
"started_at": "2026-05-06T18:42:03.812Z",
"finished_at": "2026-05-06T18:43:11.402Z",
"created_at": "2026-05-06T18:42:01.103Z",
"updated_at": "2026-05-06T18:43:11.420Z",
"error_message": "",
"ffmpeg_command": "-i {{input}} -t 30 -c:v libx264 {{preview.mp4}}",
"input_files": { "input": "https://example.com/source.mov" },
"output_files": {
"preview.mp4": "https://outputs.verygoodffmpeg.com/8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c/preview.mp4?sig=..."
},
"webhook_url": null,
"total_input_bytes": 184729123,
"total_output_bytes": 12873914
}
}GET /api/jobs
List jobs for your organisation, newest first. Supports cursor pagination.
Query parameters
| Name | Type | Description |
|---|---|---|
| limit | number | Optional. Page size, 1–100. Default 25. |
| cursor | string | Optional. Pagination cursor returned in the previous page. |
| status | string | Optional. Filter by status: queued | running | succeeded | failed | cancelled. |
Example request
curl "https://verygoodffmpeg.com/api/jobs?limit=20&status=succeeded" \
-H "Authorization: Bearer $API_KEY"Example response
{
"data": [
{
"id": "8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c",
"status": "succeeded",
"queued_at": "2026-05-06T18:42:01.114Z",
"finished_at": "2026-05-06T18:43:11.402Z",
"ffmpeg_command": "-i {{input}} -t 30 -c:v libx264 {{preview.mp4}}",
"output_files": {
"preview.mp4": "https://outputs.verygoodffmpeg.com/.../preview.mp4?sig=..."
},
"total_output_bytes": 12873914
},
{
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"status": "running",
"queued_at": "2026-05-06T18:40:22.001Z",
"finished_at": null,
"ffmpeg_command": "-i {{input}} -c:v libx264 {{out.mp4}}",
"output_files": {},
"total_output_bytes": null
}
]
}POST /api/jobs/{id}/cancel
Request cancellation of a queued or running job. Cancellation is best-effort; jobs that have already finished return their terminal state unchanged.
Example request
curl -X POST \
https://verygoodffmpeg.com/api/jobs/8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c/cancel \
-H "Authorization: Bearer $API_KEY"Example response
{
"data": {
"id": "8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c",
"status": "cancelled",
"queued_at": "2026-05-06T18:42:01.114Z",
"started_at": "2026-05-06T18:42:03.812Z",
"finished_at": "2026-05-06T18:42:31.997Z",
"error_message": "Job cancelled",
"output_files": {},
"total_output_bytes": null
}
}Successful cancellation flips the job to cancelled and emits a terminal webhook if one was configured. Partial outputs are discarded and not billed.
- If the job was already terminal, the response contains its existing state unchanged.
Sync vs async
Jobs run async by default. Use ?wait=true only for short, latency-sensitive operations.
- Async (default). The submit call returns immediately with a queued job. Combine with
webhook_urlor polling. - Sync. Pass
?wait=trueto block on the response. The request waits up to 60 seconds for the job to finish; if it doesn’t, the job is cancelled and the terminal state is returned.
Webhooks
If you supply webhook_url at submission, we POST the terminal job payload to it as soon as the job reaches a terminal state.
Delivery semantics
- At-least-once delivery — design endpoints to be idempotent on job id.
- Retried with exponential backoff for up to 24 hours on
5xxor network errors. 4xxresponses are treated as terminal — we won’t retry.- HTTPS only. Custom ports allowed; self-signed certs are not.
Payload
{
"data": {
"id": "8f3c2b6a-4d9e-4f0c-9b5a-2d3e4f5a6b7c",
"status": "succeeded",
"finished_at": "2026-05-06T18:43:11.402Z",
"output_files": {
"preview.mp4": "https://outputs.verygoodffmpeg.com/.../preview.mp4?sig=..."
},
"total_input_bytes": 184729123,
"total_output_bytes": 12873914
}
}The body is identical to the response from GET /api/jobs/{id} at the moment the job reached its terminal state.
Polling
If you can’t accept inbound webhooks, poll the job endpoint until status is terminal.
const terminal = ["succeeded", "failed", "cancelled"]
async function waitForJob(id, apiKey) {
for (let i = 0; i < 600; i++) {
const res = await fetch(`https://verygoodffmpeg.com/api/jobs/${id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
const { data } = await res.json()
if (terminal.includes(data.status)) return data
await new Promise((r) => setTimeout(r, i < 10 ? 1000 : 5000))
}
throw new Error("Job did not finish within poll budget")
}Output URLs
Outputs land at signed S3-compatible URLs. They’re stable, time-limited, and safe to hand to end users.
- Signed URLs are valid for 7 days from the moment the job is fetched. Re-fetch the job to refresh.
- Output bytes are retained for 30 days, then garbage-collected. Copy them to your own storage if you need long-term retention.
- Range requests are supported, so you can stream large outputs directly from the signed URL.
id is the durable handle.Template variables
Inputs and outputs are interpolated into your FFmpeg command via {{...}} placeholders. We resolve them to local paths inside the worker before running.
{{<input-name>}}— resolves to the local path of the downloaded input file. Names come from the keys ofinput_files.{{<output-filename>}}— resolves to a writable local path. Use the exact filename you listed inoutput_files.
Multi-input filter graph
// input_files: { intro: "...", main: "..." }
// output_files: ["combined.mp4"]
// ffmpeg_command:
-i {{intro}} -i {{main}} \
-filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" \
-map "[v]" -map "[a]" -c:v libx264 -c:a aac {{combined.mp4}}Common recipes
Drop-in commands for the most common video and audio operations. All assume {{input}} is the source.
-i {{input}} -vf scale=-2:720 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k {{preview.mp4}}-i {{input}} \
-filter_complex "[0:v]split=3[v1][v2][v3]; \
[v1]scale=854:480[v480]; \
[v2]scale=1280:720[v720]; \
[v3]scale=1920:1080[v1080]" \
-map "[v480]" -c:v:0 libx264 -b:v:0 1200k \
-map "[v720]" -c:v:1 libx264 -b:v:1 2800k \
-map "[v1080]" -c:v:2 libx264 -b:v:2 5000k \
-map a:0? -c:a aac -b:a 128k \
-f hls -hls_time 4 -hls_playlist_type vod \
-master_pl_name {{master.m3u8}} \
-hls_segment_filename "stream_%v/seg_%03d.ts" \
-var_stream_map "v:0,a:0 v:1,a:0 v:2,a:0" \
stream_%v/playlist.m3u8-i {{input}} -af loudnorm=I=-16:TP=-1.5:LRA=11 -c:a aac -b:a 192k {{normalized.m4a}}-i {{input}} -ss 5 -frames:v 1 -vf scale=640:-2 {{thumb.jpg}}-i {{input}} -filter_complex showwavespic=s=1280x256:colors=#7c3aed -frames:v 1 {{waveform.png}}Hardware acceleration
Optionally speed up your workloads by routing them to GPU workers — set machine: 'nvidia' on the submission body.
libx264 command to a GPU machine will be slower than the CPU machine, not faster — the GPU sits idle and the host CPU is weaker. To get a speed-up, your command has to use GPU decoders (*_cuvid, -hwaccel cuda), GPU encoders (*_nvenc), and ideally GPU filters (scale_npp, scale_cuda, overlay_cuda). If your pipeline isn’t written for the GPU, stay on the default CPU machine.Supported codecs
| Codec | Encode | Decode |
|---|---|---|
| H.264 | h264_nvenc | h264_cuvid |
| HEVC / H.265 | hevc_nvenc | hevc_cuvid |
| AV1 | av1_nvenc | av1_cuvid |
| VP9 | — | vp9_cuvid |
Submitting a GPU job
Set "machine": "nvidia" on the submission body and use NVENC / NVDEC codec names in your command. The default "machine": "cpu" targets our standard 16 vCPU instances.
curl -X POST https://verygoodffmpeg.com/api/ffmpeg \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_files": { "input": "https://example.com/source.mov" },
"output_files": ["encoded.mp4"],
"machine": "nvidia",
"ffmpeg_command": "-hwaccel cuda -hwaccel_output_format cuda -i {{input}} -c:v hevc_nvenc -preset p5 -rc vbr -b:v 6M {{encoded.mp4}}"
}'Errors
Errors return a structured JSON body with an errorCode, human-readable message, and optional data field for validation details.
{
"errorCode": "validationError",
"message": "Invalid FFmpeg request",
"data": [
{
"path": ["output_files"],
"message": "Array must contain at least 1 element(s)"
}
]
}Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | validationError | Request body failed schema validation. The data field lists per-field issues. |
| 400 | badRequest | Request was syntactically valid but couldn’t be acted on (e.g. job not retryable). |
| 401 | badApiKey | Missing or invalid Authorization header. |
| 400 | notFound | Job id doesn’t exist or belongs to another organisation. |
| 402 | paymentRequired | Organisation can’t submit jobs until billing is set up. data may include a billing URL. |
| 500 | unknownError | Server-side failure. Includes a trace id you can quote when contacting support. |
Pricing & billing
Usage is metered as the sum of bytes downloaded for inputs and bytes produced for outputs. Volume discounts apply automatically per calendar month.
Tiers
| Volume | Price per GB | Discount |
|---|---|---|
| 0 – 10 GB | $0.50 | — |
| 10 – 100 GB | $0.25 | 50% off |
| 100 – 500 GB | $0.15 | 70% off |
| 500 GB – 2 TB | $0.10 | 80% off |
| 2 TB+ | $0.07 | 86% off |
- Cancelled jobs aren’t billed. Failed jobs aren’t billed.
Node.js
Use the standard fetch API. The shape of the request body matches what you’d send via curl.
const apiKey = process.env.VGF_API_KEY!
const base = "https://verygoodffmpeg.com"
async function submitAndWait() {
const submit = await fetch(`${base}/api/ffmpeg`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input_files: { input: "https://example.com/source.mov" },
output_files: ["out.mp4"],
ffmpeg_command: "-i {{input}} -c:v libx264 -crf 23 {{out.mp4}}",
}),
})
const { data: queued } = await submit.json()
while (true) {
const res = await fetch(`${base}/api/jobs/${queued.id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
const { data } = await res.json()
if (["succeeded", "failed", "cancelled"].includes(data.status)) {
return data
}
await new Promise((r) => setTimeout(r, 2000))
}
}Python
Stdlib urllib works fine. Use requests if you’re already pulling it in.
import json, os, time
from urllib import request
api_key = os.environ["VGF_API_KEY"]
base = "https://verygoodffmpeg.com"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
body = json.dumps({
"input_files": {"input": "https://example.com/source.mov"},
"output_files": ["out.mp4"],
"ffmpeg_command": "-i {{input}} -c:v libx264 -crf 23 {{out.mp4}}",
}).encode()
queued = json.load(request.urlopen(
request.Request(f"{base}/api/ffmpeg", data=body, headers=headers, method="POST")
))["data"]
terminal = {"succeeded", "failed", "cancelled"}
while True:
poll = request.Request(f"{base}/api/jobs/{queued['id']}", headers=headers)
data = json.load(request.urlopen(poll))["data"]
if data["status"] in terminal:
print(data["output_files"])
break
time.sleep(2)