Your first job
The quick start shows the shortest path. This page walks the same ground with the parts a real integration needs: choosing the preset deliberately, handling the refusals, waiting properly, and dealing with the failures that happen after acceptance.
Pick a preset
mp4_264_720p is a good first choice. It is on every plan, H.264 plays
everywhere, and it produces one file rather than an archive.
To see what your own key can submit, ask:
curl -s https://videotranscode.cloud/api/v1/presets \
-H "Authorization: Bearer $VT_API_KEY" \
| jq -r '.presets[] | select(.available) | "\(.id)\t\(.name)"'Choosing a preset covers the decision properly.
Prepare the source
The source URL has to be reachable from the public internet at the moment the job runs, which may be minutes after you create it. That rules out three things people try:
- URLs behind a login, since we have no session.
- Signed URLs that expire in a few minutes, since the queue may outlast them.
- Private or internal addresses.
Check it the way we will:
curl -sI "https://cdn.example.com/master.mov" | head -1Also check the length against your plan. Free allows 10 minutes, Pro 60, Business 180, Enterprise 480. Over the limit, the job is accepted, then fails, and still costs you a job.
Create the job
curl -s -X POST https://videotranscode.cloud/api/v1/jobs \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inputUrl": "https://cdn.example.com/master.mov",
"preset": "mp4_264_720p",
"metadata": { "uploadId": "u_8842", "tenant": "acme" }
}'metadata is optional. Anything you put there is stored with the job, which is
the simplest way to tie our job identifier back to your own records.
201 Created:
{
"id": "9d1f5c0a-4e7b-4a61-9b2e-8f1c3d5a7e42",
"status": "queued",
"preset": "mp4_264_720p",
"inputUrl": "https://cdn.example.com/master.mov",
"webhookUrl": null,
"createdAt": "2026-09-10T14:08:11.204Z",
"estimatedProcessingTime": "2-5 minutes",
"links": {
"self": "/api/v1/jobs/9d1f5c0a-4e7b-4a61-9b2e-8f1c3d5a7e42",
"cancel": "/api/v1/jobs/9d1f5c0a-4e7b-4a61-9b2e-8f1c3d5a7e42"
},
"user": {
"remainingJobs": 9,
"plan": "Starter"
}
}Store id against your own record now, before you wait for anything. If your
process dies here, the job still runs, and the identifier is the only way back
to it.
Handle the refusals
Three of them are worth handling by hand, because each body tells you how to recover.
const res = await fetch('https://videotranscode.cloud/api/v1/jobs', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ inputUrl, preset })
})
if (!res.ok) {
const body = await res.json()
if (res.status === 403) {
// Preset exists, your plan does not include it.
throw new Error(`Try one of: ${body.availablePresets.join(', ')}`)
}
if (res.status === 429) {
// Quota, not a rate limit. Retrying will not help this month.
throw new Error(`Used ${body.usage.current} of ${body.usage.limit} jobs`)
}
if (res.status === 400 && body.details) {
// details names every field that failed.
throw new Error(body.details.map(d => `${d.field}: ${d.message}`).join('; '))
}
throw new Error(body.message ?? `Request failed with ${res.status}`)
}
const job = await res.json()Every status and body is in Errors.
Wait for it
Read the job until its status is terminal. Give the loop a deadline, and treat
5xx as retryable while anything else is fatal.
const TERMINAL = new Set(['completed', 'failed', 'cancelled'])
const sleep = ms => new Promise(r => setTimeout(r, ms))
async function waitForJob(jobId, { intervalMs = 12_000, timeoutMs = 45 * 60_000 } = {}) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetch(`https://videotranscode.cloud/api/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.VT_API_KEY}` }
})
if (res.status >= 500) { await sleep(intervalMs); continue }
if (!res.ok) throw new Error(`Job read failed: ${res.status}`)
const job = await res.json()
if (TERMINAL.has(job.status)) return job
await sleep(intervalMs)
}
throw new Error(`Job ${jobId} did not finish in time`)
}Polling every 10 to 15 seconds is plenty. Encoding takes minutes.
For a service rather than a script, configure a webhook and skip the loop. See Webhooks or polling.
Read the outcome
A terminal job is one of three things.
const job = await waitForJob(id)
switch (job.status) {
case 'completed':
await download(job.outputUrl) // expires at job.expiration
break
case 'failed':
await handleFailure(job.errorMessage) // see the failures guide
break
case 'cancelled':
break // somebody cancelled it
}Completed:
{
"id": "9d1f5c0a-4e7b-4a61-9b2e-8f1c3d5a7e42",
"status": "completed",
"preset": "mp4_264_720p",
"outputUrl": "https://sfo3.digitaloceanspaces.com/...&X-Amz-Signature=...",
"expiration": "2026-09-10T18:14:52.881Z",
"duration": 742,
"errorMessage": null,
"retryCount": 0,
"metadata": {
"createdAt": "2026-09-10T14:08:11.204Z",
"startedAt": "2026-09-10T14:08:19.663Z",
"completedAt": "2026-09-10T14:12:47.118Z",
"lastCheckedAt": "2026-09-10T14:12:47.118Z"
}
}Failed:
{
"id": "9d1f5c0a-4e7b-4a61-9b2e-8f1c3d5a7e42",
"status": "failed",
"outputUrl": null,
"expiration": null,
"errorMessage": "DURATION_EXCEEDED: Video duration (24 minutes) exceeds plan limit (10 minutes)",
"retryCount": 0
}retryCount is how many times our worker retried before giving up. By the time
you see failed, those retries are finished.
Download and keep it
import { createWriteStream } from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
const res = await fetch(job.outputUrl) // no auth header; signature is in the URL
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
await pipeline(Readable.fromWeb(res.body), createWriteStream('output.mp4'))The link is what expires, not necessarily the file: automatic deletion by plan is planned but not switched on yet. Build against the link lifetime anyway, and if you need the file next month, copy it to your own storage now. Do not store the signed URL as if it were permanent; store the job identifier and sign again when you need a link.
What to read next
- Webhooks to stop polling.
- Handling failures for every way a job can fail.
- Adaptive streaming when one file is not enough.
- EDL to trim, reorder and join clips instead of transcoding one file.