Direct Uploads
Jobs normally take a URL Xora can fetch. When your file only exists on a laptop,
a CI runner, or inside a private network, direct uploads give you somewhere
to put it: ask for a presigned upload, PUT the bytes, then reference the
upload by id when you create the job.
POST /v1/uploads ──▶ { uploadId, url, expiresAt, maxBytes } │ ├── PUT <url> (raw file bytes, no auth header) │ └── POST /v1/jobs { "input": { "uploadId": "…" }, … }Uploads are transient input storage, not a media library: objects are deleted automatically 3 days after upload. Job outputs are unaffected and persist until you delete them (or land in your own bucket).
Upload a file
Section titled “Upload a file”-
Request a presigned upload
Send the size of the file you’re about to upload. Xora checks it against your plan’s per-file limit before handing back a URL, so you find out you’re over the cap before spending time on the transfer.
Terminal window curl -X POST https://api.xora.sh/v1/uploads \-H "Authorization: Bearer YOUR_API_KEY" \-H "Content-Type: application/json" \-d '{"sizeBytes": 734003200,"filename": "interview-cam-a.mp4"}'import { statSync } from 'node:fs';const path = 'interview-cam-a.mp4';const res = await fetch('https://api.xora.sh/v1/uploads', {method: 'POST',headers: {Authorization: `Bearer ${process.env.XORA_API_KEY}`,'Content-Type': 'application/json',},body: JSON.stringify({sizeBytes: statSync(path).size,filename: path,}),});const { uploadId, url } = await res.json();import os, requestspath = "interview-cam-a.mp4"res = requests.post("https://api.xora.sh/v1/uploads",headers={"Authorization": f"Bearer {os.environ['XORA_API_KEY']}","Content-Type": "application/json",},json={"sizeBytes": os.path.getsize(path), "filename": path},)upload = res.json()You get back a
201with the presigned URL:{"uploadId": "01KSD388ERH85FGR79JCW0SZZ0","url": "https://xora-uploads.s3.us-east-1.amazonaws.com/uploads/user_2ab…/01KSD388…mp4?X-Amz-Signature=…","expiresAt": "2026-07-24T13:00:00.000Z","maxBytes": 2147483648}filenameis optional and only its extension is used, to give the stored object a sensible suffix.maxBytesis the ceiling this upload will accept. -
PUT the file bytes
Send the raw file as the request body — a single
PUT, noAuthorizationheader (the signature is in the URL), and no form encoding. The URL is valid for one hour.Terminal window curl -X PUT "PRESIGNED_URL_HERE" \--upload-file interview-cam-a.mp4import { openAsBlob } from 'node:fs';await fetch(url, {method: 'PUT',body: await openAsBlob(path),});with open(path, "rb") as f:requests.put(upload["url"], data=f)A
200with anETagheader means the object is stored. Browsers canPUTdirectly too — the bucket allows cross-originPUTfrom the Xora dashboard origin, so an upload never proxies through your own server. -
Create a job against the upload
Swap
input.urlforinput.uploadId. Everything else about the job body is unchanged.Terminal window curl -X POST https://api.xora.sh/v1/jobs \-H "Authorization: Bearer YOUR_API_KEY" \-H "Content-Type: application/json" \-d '{"mode": "recipe","input": { "uploadId": "01KSD388ERH85FGR79JCW0SZZ0" },"output": { "format": "mp4" },"recipe": { "name": "compress", "crf": 23 }}'const job = await fetch('https://api.xora.sh/v1/jobs', {method: 'POST',headers: {Authorization: `Bearer ${process.env.XORA_API_KEY}`,'Content-Type': 'application/json',},body: JSON.stringify({mode: 'recipe',input: { uploadId },output: { format: 'mp4' },recipe: { name: 'compress', crf: 23 },}),}).then((r) => r.json());job = requests.post("https://api.xora.sh/v1/jobs",headers={"Authorization": f"Bearer {os.environ['XORA_API_KEY']}","Content-Type": "application/json",},json={"mode": "recipe","input": {"uploadId": upload["uploadId"]},"output": {"format": "mp4"},"recipe": {"name": "compress", "crf": 23},},).json()From here it’s an ordinary job — poll
GET /v1/jobs/{id}or take a webhook, then download the output.
Where uploads work
Section titled “Where uploads work”input.uploadId is accepted anywhere a single input.url is:
| Mode | Supported |
|---|---|
mode: "recipe" (single-input recipes) | Yes |
mode: "probe" | Yes |
mode: "ffmpeg" with input / output | Yes |
mode: "ffmpeg" with input_files / output_files | No — multi-input jobs take URLs |
concat recipe (input_files) | No — takes URLs |
| Running a saved preset | Recipe presets: yes — pass input: { uploadId }. ffmpeg/concat presets: URL-only |
input accepts exactly one of url or uploadId. Sending both, or neither,
is a 400 VALIDATION_ERROR.
Limits and lifetime
Section titled “Limits and lifetime”| Max file size | 5 GB, or your plan’s per-file input limit if it’s lower |
| Upload URL validity | 1 hour from creation |
| Object retention | 3 days, then deleted automatically |
| Reuse | An upload can be used by any number of jobs until it expires |
| Cost | Uploading is free — inputs are never billed. See pricing |
Uploads are scoped to the account that created them: another account’s
uploadId reads as not found.
Re-running a job on the same upload
Section titled “Re-running a job on the same upload”Nothing is consumed by creating a job. If a job fails and you want to retry
with different settings, or you want a thumbnail and an MP4 from one source,
create a second job with the same uploadId — no re-upload needed, as long as
the 3-day window hasn’t passed.
Files larger than 5 GB
Section titled “Files larger than 5 GB”Direct uploads use a single PUT, which caps at 5 GB. If your plan allows
larger inputs (Scale allows 6 GB), host the file and pass input.url instead.
Multipart upload support is planned.
Errors
Section titled “Errors”error.code | HTTP | Meaning |
|---|---|---|
LIMIT_EXCEEDED | 402 | The declared sizeBytes, or the file you actually uploaded, is over your plan’s per-file limit. |
VALIDATION_ERROR | 400 | On /v1/uploads: sizeBytes is missing, not positive, or over 5 GB. On /v1/jobs: the upload id is unknown, expired, or the bytes were never uploaded. |
The message upload not found or not yet uploaded almost always means step 2
didn’t finish — the upload record exists but the object doesn’t. Complete the
PUT, then create the job.
Note that the size check at job creation reads the object Xora actually
stored, not the sizeBytes you declared. Under-declaring to get a URL doesn’t
get an oversized file past the plan limit; the job is refused instead.
Agents and MCP
Section titled “Agents and MCP”The MCP server exposes the same flow as a create_upload
tool, so an agent holding a local file can chain
create_upload → PUT → create_job without a hosting step.
Related
Section titled “Related”- Quickstart — your first job, using a public URL
- Input staging —
stageInputfor external URLs - Storage providers (BYOB) — durable delivery of outputs
- Error handling — rejected vs failed, retries