DocsGet started
Quickstart
Create an API key, send a JSON request, poll the job until it ends and download its output.
Send a JSON request with an input URL and the output settings you need. Videxo returns a job ID. Poll the job or receive a webhook, then download the completed files. Already have an ffmpeg command? Send it as it is.
Videxo is a development preview. Jobs run, store their outputs and send signed webhooks. Uploads up to 100 MB and signed output downloads are available. Sign in with email, GitHub or a passkey. Payments are not connected yet, so credit packs and paid plans cannot be bought.
Before you start#
- An account. Sign up with your email; new accounts start on Pay as you go with no card, and your personal organization gets 500 free credits once, valid 30 days.
curlandjq(opens in a new tab) for the shell steps, or Node 18 or newer for the JavaScript steps.
The steps below are one sequence: each shell step uses the variables the step before it set, and
the JavaScript steps go into one file, quickstart.mjs, that you run with node quickstart.mjs.
1. Create an API key#
In the dashboard, open API keys and choose Create key. Name it after where it runs, such as
production-worker. The key is shown once: copy it into your secret store, then export it with
the API's address:
export VIDEXO_API_URL="https://api.videxo.io"
export VIDEXO_API_KEY="<your key>"Copy failed. The text is selected; copy it with your keyboard.
Every example reads these two variables. Check that the key works. The response names your account and its plan:
curl "$VIDEXO_API_URL/v1/me" \
-H "Authorization: Bearer $VIDEXO_API_KEY"Copy failed. The text is selected; copy it with your keyboard.
const response = await fetch(`${process.env.VIDEXO_API_URL}/v1/me`, {
headers: { Authorization: `Bearer ${process.env.VIDEXO_API_KEY}` },
});
const me = await response.json();Copy failed. The text is selected; copy it with your keyboard.
2. Create a job#
POST /v1/transcode makes one video from one input. input is an http or https URL, or a
completed private upload. This one is our 15-second sample video
(https://videxo.io/samples/talk.mp4, public domain), so the request runs as shown. preset names a set of settings
(Presets), here an MP4 for the web within 1280 by 720. The ffmpeg tab shows the
command the request runs.
The Idempotency-Key header makes the request safe to repeat: if the network fails before you
see the response, send the same request with the same key and you get the same job instead of a
second one (Idempotency).
idempotency_key="quickstart-$(date +%s)"
job=$(curl --fail-with-body -sS "$VIDEXO_API_URL/v1/transcode" \
-H "Authorization: Bearer $VIDEXO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $idempotency_key" \
-d '{
"input": "https://videxo.io/samples/talk.mp4",
"preset": "web-720p",
"filename": "talk-720p.mp4",
"metadata": {
"order": "1234"
}
}')
job_id=$(printf '%s' "$job" | jq -r .id)
echo "$job_id"Copy failed. The text is selected; copy it with your keyboard.
import { randomUUID } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
const response = await fetch(`${process.env.VIDEXO_API_URL}/v1/transcode`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VIDEXO_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
"input": "https://videxo.io/samples/talk.mp4",
"preset": "web-720p",
"filename": "talk-720p.mp4",
"metadata": {
"order": "1234"
}
}),
});
const job = await response.json();
// Errors are problem details: a stable `code` and a `detail` for people.
if (!response.ok) throw new Error(`${response.status} ${job.code}: ${job.detail}`);
console.log(job.id);Copy failed. The text is selected; copy it with your keyboard.
ffmpeg \
-i https://videxo.io/samples/talk.mp4 \
-map 0:v:0 \
-vf "scale='min(iw,1280)':'min(ih,720)':force_original_aspect_ratio=decrease:force_divisible_by=2" \
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \
-map "0:a:0?" \
-c:a aac -b:a 128k -movflags +faststart \
talk-720p.mp4Copy failed. The text is selected; copy it with your keyboard.
The API returns HTTP 202 and a queued job. The shell step saves its id in job_id for the next
step. The response also includes your normalized request (spec) and the equivalent ffmpeg
command:
{
"id": "job_3k9x0c2mfq8w1n7r5t4b6v2z",
"state": "queued",
"kind": "operations",
"operation": "transcode",
"spec": {
"inputs": {
"source": {
"url": "https://videxo.io/samples/talk.mp4"
}
},
"outputs": [
{
"name": "video",
"type": "video",
"preset": "web-720p",
"input": "source",
"filename": "talk-720p.mp4",
"format": "mp4",
"resize": {
"width": 1280,
"height": 720,
"fit": "contain",
"enlarge": false
},
"video": {
"codec": "h264",
"crf": 23,
"encoder_preset": "veryfast",
"pixel_format": "yuv420p",
"faststart": true
},
"audio": {
"codec": "aac",
"bitrate": "128k"
}
}
]
},
"command": "-i {{in_source}} -map 0:v:0 -vf \"scale='min(iw,1280)':'min(ih,720)':force_original_aspect_ratio=decrease:force_divisible_by=2\" -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map 0:a:0? -c:a aac -b:a 128k -movflags +faststart {{out_video}}",
"input_files": {
"in_source": "https://videxo.io/samples/talk.mp4"
},
"output_files": {
"out_video": "talk-720p.mp4"
},
"ffmpeg_command": "ffmpeg -i https://videxo.io/samples/talk.mp4 -map 0:v:0 -vf 'scale='\\''min(iw,1280)'\\'':'\\''min(ih,720)'\\'':force_original_aspect_ratio=decrease:force_divisible_by=2' -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map '0:a:0?' -c:a aac -b:a 128k -movflags +faststart talk-720p.mp4",
"steps": [
{
"tool": "ffmpeg",
"inputs": [
"source"
],
"outputs": [
"video"
],
"command": "ffmpeg -i https://videxo.io/samples/talk.mp4 -map 0:v:0 -vf 'scale='\\''min(iw,1280)'\\'':'\\''min(ih,720)'\\'':force_original_aspect_ratio=decrease:force_divisible_by=2' -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map '0:a:0?' -c:a aac -b:a 128k -movflags +faststart talk-720p.mp4"
}
],
"webhook_url": null,
"metadata": {
"order": "1234"
},
"progress": null,
"error": null,
"outputs": [],
"usage": null,
"created_at": "2026-11-04T12:00:00.000Z",
"started_at": null,
"finished_at": null
}Copy failed. The text is selected; copy it with your keyboard.
A request with an invalid field returns 400 invalid_request with a pointer to each field to
change, and no job is created (Error codes).
3. Poll the job#
Read the job by its ID until its state is succeeded, failed or cancelled. Wait a few
seconds between requests, and read the job only from a successful response: after 429 rate_limited or a 5xx response, try again later (after Retry-After when it is sent); any
other error means stop and read its code.
# Ask every 5 seconds until the job ends. A 429, 5xx or dropped connection is asked again after
# Retry-After (or a pause that doubles, up to a minute); any other error stops with its body.
wait_for_job() {
body=$(mktemp); headers=$(mktemp); wait=5
while true; do
status=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' "$VIDEXO_API_URL/v1/jobs/$1" \
-H "Authorization: Bearer $VIDEXO_API_KEY")
if [ "$status" -ge 200 ] && [ "$status" -lt 300 ]; then
case "$(jq -r .state "$body")" in
succeeded|failed|cancelled) cat "$body"; rm -f "$body" "$headers"; return 0 ;;
esac
wait=5
elif [ "$status" = 429 ] || [ "$status" -ge 500 ] || [ "$status" = 000 ]; then
retry=$(grep -i '^retry-after:' "$headers" | tr -dc '0-9')
if [ -n "$retry" ]; then wait=$retry; else wait=$((wait * 2 > 60 ? 60 : wait * 2)); fi
else
cat "$body" >&2; rm -f "$body" "$headers"; return 1
fi
sleep "$wait"
done
}
# $job_id is the id step 2 saved.
job=$(wait_for_job "$job_id") && printf '%s' "$job" | jq '{state, error}'Copy failed. The text is selected; copy it with your keyboard.
const TERMINAL = ['succeeded', 'failed', 'cancelled'];
// Ask every 5 seconds until the job ends. A 429 or 5xx answer is asked again after Retry-After
// (or a pause that doubles, up to a minute); any other error is thrown with its code.
async function waitForJob(id) {
let wait = 5;
for (;;) {
const response = await fetch(`${process.env.VIDEXO_API_URL}/v1/jobs/${id}`, {
headers: { Authorization: `Bearer ${process.env.VIDEXO_API_KEY}` },
});
if (response.ok) {
const job = await response.json();
if (TERMINAL.includes(job.state)) return job;
wait = 5;
} else if (response.status === 429 || response.status >= 500) {
const retryAfter = response.headers.get('retry-after');
wait = retryAfter !== null && Number(retryAfter) >= 0 ? Number(retryAfter) : Math.min(wait * 2, 60);
} else {
// Errors are problem details: a stable `code` and a `detail` for people.
const problem = await response.json();
throw new Error(`${response.status} ${problem.code}: ${problem.detail}`);
}
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}
// `job` is the job step 2 created.
const finished = await waitForJob(job.id);
console.log(finished.state, finished.error ?? '');Copy failed. The text is selected; copy it with your keyboard.
Each request counts against your plan's rate limit. Polling every 5 seconds uses 12 requests a minute. A webhook tells you when the job ends instead.
4. Download the output#
A job that succeeded lists each output with its name, file name, size, SHA-256 and the time it expires, and its usage in seconds, bytes and credits:
{
"id": "job_3k9x0c2mfq8w1n7r5t4b6v2z",
"state": "succeeded",
"kind": "operations",
"operation": "transcode",
"spec": {
"inputs": {
"source": {
"url": "https://videxo.io/samples/talk.mp4"
}
},
"outputs": [
{
"name": "video",
"type": "video",
"preset": "web-720p",
"input": "source",
"filename": "talk-720p.mp4",
"format": "mp4",
"resize": {
"width": 1280,
"height": 720,
"fit": "contain",
"enlarge": false
},
"video": {
"codec": "h264",
"crf": 23,
"encoder_preset": "veryfast",
"pixel_format": "yuv420p",
"faststart": true
},
"audio": {
"codec": "aac",
"bitrate": "128k"
}
}
]
},
"command": "-i {{in_source}} -map 0:v:0 -vf \"scale='min(iw,1280)':'min(ih,720)':force_original_aspect_ratio=decrease:force_divisible_by=2\" -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map 0:a:0? -c:a aac -b:a 128k -movflags +faststart {{out_video}}",
"input_files": {
"in_source": "https://videxo.io/samples/talk.mp4"
},
"output_files": {
"out_video": "talk-720p.mp4"
},
"ffmpeg_command": "ffmpeg -i https://videxo.io/samples/talk.mp4 -map 0:v:0 -vf 'scale='\\''min(iw,1280)'\\'':'\\''min(ih,720)'\\'':force_original_aspect_ratio=decrease:force_divisible_by=2' -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map '0:a:0?' -c:a aac -b:a 128k -movflags +faststart talk-720p.mp4",
"steps": [
{
"tool": "ffmpeg",
"inputs": [
"source"
],
"outputs": [
"video"
],
"command": "ffmpeg -i https://videxo.io/samples/talk.mp4 -map 0:v:0 -vf 'scale='\\''min(iw,1280)'\\'':'\\''min(ih,720)'\\'':force_original_aspect_ratio=decrease:force_divisible_by=2' -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map '0:a:0?' -c:a aac -b:a 128k -movflags +faststart talk-720p.mp4"
}
],
"webhook_url": null,
"metadata": {
"order": "1234"
},
"progress": 1,
"error": null,
"outputs": [
{
"alias": "out_video",
"name": "video",
"filename": "talk-720p.mp4",
"size_bytes": 300000000,
"sha256": "9f2c4e1a7b3d5f60c8e2a4b6d8f0e1c3a5b7d9f1e3c5a7b9d1f3e5c7a9b1d3f5",
"content_type": "video/mp4",
"expires_at": "2026-11-05T12:01:09.000Z",
"download_url": "https://api.videxo.io/v1/files/job_3k9x0c2mfq8w1n7r5t4b6v2z/out_video?account=acc_example&expires=1793797269&signature=example",
"download_url_expires_at": "2026-11-04T13:01:09.000Z",
"deleted_at": null
}
],
"usage": {
"seconds": 61,
"input_bytes": 1200000000,
"output_bytes": 300000000,
"credits": 45,
"bill": {
"lines": [
{
"kind": "video",
"seconds": 300,
"per_minute": 6,
"multiplier": 1,
"credits": 30
}
],
"data": {
"input_bytes": 1200000000,
"output_bytes": 300000000,
"credits": 15
},
"processing": {
"seconds": 61,
"included_seconds": 900,
"credits": 0
}
}
},
"created_at": "2026-11-04T12:00:00.000Z",
"started_at": "2026-11-04T12:00:04.000Z",
"finished_at": "2026-11-04T12:01:09.000Z"
}Copy failed. The text is selected; copy it with your keyboard.
Download an output from its download_url, without your API key:
curl --fail-with-body -sS "$(printf '%s' "$job" | jq -r '.outputs[0].download_url')" -o talk-720p.mp4Copy failed. The text is selected; copy it with your keyboard.
if (finished.state !== 'succeeded') throw new Error(`The job ${finished.state}: ${finished.error?.message}`);
const file = await fetch(finished.outputs[0].download_url);
if (!file.ok) throw new Error(`The download returned ${file.status}`);
await writeFile('talk-720p.mp4', Buffer.from(await file.arrayBuffer()));Copy failed. The text is selected; copy it with your keyboard.
A link lasts up to one hour, and never past the output's retention. Read the job again for a fresh link. Files covers range requests, private input uploads up to 100 MB and expiry.
A failed job has an error with a code and a message instead of outputs. Jobs and their
states explains every state and every error code.
Several outputs in one job#
POST /v1/jobs takes named inputs and a list of outputs. Each output reads an input and has its
own type, preset and options. A job reads each input once, so this costs less than one job per
output:
curl "$VIDEXO_API_URL/v1/jobs" \
-H "Authorization: Bearer $VIDEXO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inputs": {
"talk": "https://videxo.io/samples/talk.mp4"
},
"outputs": [
{
"name": "web",
"preset": "web-720p"
},
{
"name": "poster",
"preset": "poster"
}
],
"metadata": {
"order": "1234"
}
}'Copy failed. The text is selected; copy it with your keyboard.
const response = await fetch(`${process.env.VIDEXO_API_URL}/v1/jobs`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VIDEXO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"inputs": {
"talk": "https://videxo.io/samples/talk.mp4"
},
"outputs": [
{
"name": "web",
"preset": "web-720p"
},
{
"name": "poster",
"preset": "poster"
}
],
"metadata": {
"order": "1234"
}
}),
});
const job = await response.json();Copy failed. The text is selected; copy it with your keyboard.
ffmpeg \
-i https://videxo.io/samples/talk.mp4 \
-map 0:v:0 \
-vf "scale='min(iw,1280)':'min(ih,720)':force_original_aspect_ratio=decrease:force_divisible_by=2" \
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \
-map "0:a:0?" \
-c:a aac -b:a 128k -movflags +faststart \
web.mp4 \
-map 0:v:0 \
-vf "thumbnail=300,scale='min(iw,1280)':'min(ih,720)':force_original_aspect_ratio=decrease:force_divisible_by=2" \
-frames:v 1 -q:v 2 \
poster.jpgCopy failed. The text is selected; copy it with your keyboard.
Operations lists every output type and option; the guides show them by task, starting with Compress and convert.
Next#
- Authentication and API basics: keys, headers, environments and versioning.
- Guides: compress, resize, trim, watermark, thumbnails, GIFs and audio, each as a JSON request with the ffmpeg command it runs.
- Operations and Presets: every output type, option and preset.
- Jobs and their states: the lifecycle, listing jobs, cancelling and deleting files.
- Migrating from your own ffmpeg: send the commands you already have.
- API reference: every endpoint and object.