Downloading output
Output is written to private object storage. Nothing we produce is publicly readable, so every download goes through a signed URL with an expiry stamped into it.
Getting a link
Reading a job returns one. outputUrl is signed at the moment you read, and
expiration says when that particular link stops working.
curl https://videotranscode.cloud/api/v1/jobs/$JOB_ID \
-H "Authorization: Bearer $VT_API_KEY"{
"status": "completed",
"outputUrl": "https://sfo3.digitaloceanspaces.com/...&X-Amz-Signature=...",
"expiration": "2026-09-17T14:12:47.118Z"
}If you want a link without the rest of the job, GET /jobs/{id}/download-url
returns just that, as downloadUrl with expiresAt.
The signature is in the query string, so downloading needs no Authorization
header:
curl -o output.mp4 "$OUTPUT_URL"How long you can reach the output
Link lifetime is how long one signed URL stays valid.
| Plan | Link valid for |
|---|---|
| Free | 4 hours |
| Pro | 7 days |
| Business | 7 days |
| Enterprise | 7 days |
Seven days is a hard ceiling in the storage signer, so no link is ever valid for longer, whatever the plan.
Because links are signed at the moment you read the job, an expired link is never a problem. Read the job again and you get a new one. Do not store a signed URL in your database as if it were a permanent address; store the job identifier and sign on demand.
Storage retention is not currently enforced. Automatic deletion by plan is planned but not switched on, so output is not being removed on a schedule today. Treat that as an absence of a guarantee rather than a promise of permanence: it will be switched on, and when it is, the intended windows are about a day for Free, 7 days for Pro and 30 days for Business and Enterprise. Day-level granularity is the finest the storage lifecycle rules allow, so the free plan's 4 hours will continue to be expressed by the link lifetime rather than by deletion.
The practical answer for building against this today: the presigned URL lifetime is the access window you can rely on. Anything you need beyond it, copy to your own storage.
Download it to your own storage
Do not treat our storage as your archive. If you need the file next month, copy it to storage you control as soon as the job completes. The natural place to do that is your webhook handler.
async function onCompleted(payload) {
const res = await fetch(payload.outputUrl)
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
await uploadToYourBucket(`videos/${payload.jobId}.mp4`, res.body)
await markReady(payload.jobId)
}The webhook payload already carries outputUrl, so a handler does not need to
call the API back before downloading.
What you get for each preset
Single-file presets, meaning the progressive MP4, social and audio ones,
produce exactly one file and outputUrl points at it.
Streaming, thumbnail and sprite presets produce many files, so the output is a single ZIP archive. Unpack it before serving. See Adaptive streaming for the layout and for how to publish it.
When there is no link
outputUrl is null in four situations, and they need different responses.
| Situation | What to do |
|---|---|
status is queued or processing | Wait; the job is not finished |
status is failed or cancelled | Read errorMessage; there is no output |
| Completed, but the output has been removed | Run the job again |
| Completed, but stored under an old layout | Run the job again; the file cannot be signed |
The last case only affects a small number of jobs created before the current
storage layout. Asking for a link explicitly returns 410 Gone with a plain
message rather than a null.