User video uploads

Process user-uploaded video in the cloud

Users upload MOV, HEVC, huge phone files and broken files. Send each one to Xora, and get back an MP4 that plays everywhere, a thumbnail, and a webhook when it is done.

Uploads are the hard part of video

Any app that takes video from users gets every kind of file: iPhone HEVC, 4K screen recordings, MOV files from cameras, files with no audio, and files that are not video at all. Browsers play only some of them, and a 2 GB upload is not something to serve as is.

Running FFmpeg on your own servers for this means a queue, workers, disk space and a way to isolate untrusted files. Xora runs the FFmpeg part. Your app sends a URL and gets a webhook.

One upload, three jobs
  1. User uploads to your storage
  2. probe → is it a video? how long?
  3. webReady → MP4 that plays everywhere
  4. thumbnail → poster image

The pipeline

Three API calls per upload. Each job is independent, so a failed thumbnail does not block the video.

  1. Check the file

    Run mode: "probe" on the upload URL. Read hasVideo, durationSeconds, width, height and videoCodec, and reject files that do not fit your rules before you spend anything on an encode.

  2. Make it play everywhere

    Run the webReady recipe. It passes through files that already play in browsers, remuxes when only the container or index must change, and re-encodes HEVC, ProRes and other formats to H.264 MP4. Add scaleDown: true to limit the size.

  3. Add a poster image

    Run the thumbnail recipe with seekSeconds and stageInput: false. When the storage supports range requests, only the part of the file around that time is read.

From your upload handler

Send the jobs when the upload lands in your storage. Use the upload ID in each idempotencyKey, so a retry of your handler does not start the same job twice. A presigned GET URL from S3 or R2 works as the input.

When a job ends, Xora sends a POST to webhookUrl with data.jobId, data.state and, for a completed job, data.output.signedUrl. Save the URL, or let the output land in your own R2 bucket.

For files that are on your server and have no URL, use a direct upload: POST /v1/uploads, one PUT, then pass the uploadId to the job.

Node.js — after an upload
const XORA = 'https://api.xora.sh/v1/jobs'
const headers = {
Authorization: `Bearer ${process.env.XORA_API_KEY}`,
'Content-Type': 'application/json',
}
const webhookUrl = 'https://your-app.com/webhooks/xora'
async function processUpload(uploadId, fileUrl) {
const jobs = [
{ mode: 'probe', input: { url: fileUrl } },
{
mode: 'recipe',
input: { url: fileUrl },
output: { format: 'mp4' },
recipe: { name: 'webReady', scaleDown: true, height: 1080 },
},
{
mode: 'recipe',
input: { url: fileUrl },
output: { format: 'jpg' },
recipe: { name: 'thumbnail', seekSeconds: 1 },
stageInput: false,
},
]
const names = ['probe', 'web', 'thumb']
return Promise.all(
jobs.map((job, i) =>
fetch(XORA, {
method: 'POST',
headers,
body: JSON.stringify({ ...job, webhookUrl, idempotencyKey: `${uploadId}_${names[i]}` }),
}).then((r) => r.json()),
),
)
}

Limits and cost

The same rules apply to every job, so you can plan for them.

PlanMax input fileJobs at onceCredits each month
Free250 MB225
Build2 GB10400
Scale6 GB501500

Credits are billed for machine time and output size. A probe or a thumbnail uses a small part of a credit, and an upload that is already web-ready is passed through without an encode. Failed, rejected and cancelled jobs cost nothing. See pricing for worked examples.

More detail: ffprobe API · web-ready video settings · thumbnails from a URL

Frequently asked questions

How do I process user-uploaded video with FFmpeg?

Let the user upload to your storage, then send the file URL to an FFmpeg API. With Xora, run a probe job to check the file, a webReady job to make an MP4 that plays in every browser, and a thumbnail job for the poster image. A webhook tells your app when each one is done.

What happens if a user uploads a file that is not a video?

The probe job tells you what the file contains, with fields such as hasVideo, durationSeconds and videoCodec. If the file has no video stream or cannot be read, reject it before you run an encode. Failed and rejected Xora jobs cost nothing.

How large can an upload be?

The input limit depends on the Xora plan, from 250 MB on Free to 6 GB on Scale. Check the size in your own upload step as well, so users get a clear message before they upload.

Do I need to re-encode every upload?

No. The webReady recipe probes the file first. It returns files that already play in browsers as they are, remuxes files that only need a container or index change, and re-encodes only the rest.

Can the processed files go to my own storage?

Yes. On paid plans, connect a Cloudflare R2 bucket once, and every output is written to it. On every plan, outputs are also available as signed URLs.

Handle your first upload today

Start on the Free plan with no card. Send one upload URL and get a web-ready MP4 and a thumbnail back.