# Xora > FFmpeg in the cloud, for agents & humans. Xora runs real FFmpeg argument > arrays behind one REST endpoint: POST a job with raw args or a named recipe, > poll deterministic job state (or take a webhook), and collect outputs from a > signed URL or your own Cloudflare R2 bucket. Base URL: https://api.xora.sh/v1 Auth: `Authorization: Bearer ` (create keys in the dashboard at https://xora.sh/app) OpenAPI spec: https://api.xora.sh/v1/openapi.json Interactive reference: https://xora.sh/docs/api MCP server: https://api.xora.sh/v1/mcp — remote MCP over Streamable HTTP, same Bearer auth; tools: create_upload, create_job, get_job, list_recipes, cancel_job. Connect: `claude mcp add --transport http xora https://api.xora.sh/v1/mcp --header "Authorization: Bearer "` Agent skill: https://xora.sh/skills/xora/SKILL.md — recipe selection, job loop, and error-branching guidance for Claude; install to ~/.claude/skills/xora/ Core loop: 1. `POST /v1/jobs` with `{"mode":"ffmpeg","input_files":{"in_1":""},"output_files":{"out_1":"out.mp4"},"ffmpeg":{"args":["-i","{{in_1}}","...","{{out_1}}"]}}` or `{"mode":"recipe","input":{"url":""},"output":{"format":"mp4"},"recipe":{"name":"compress","crf":23}}` 2. Poll `GET /v1/jobs/{id}` until `state` is terminal: `completed | failed | rejected | cancelled` (progress reported in percent; pass `webhookUrl` to be called back instead of polling). 3. Download the output via the signed URL on the job, or configure BYOB delivery to your own R2 bucket so outputs never need collecting. Local files (no public URL): `POST /v1/uploads` with `{"sizeBytes":,"filename":"clip.mp4"}` returns `{uploadId, url, expiresAt, maxBytes}`; `PUT` the raw bytes to `url` (1 hour, no auth header), then create the job with `"input":{"uploadId":""}` in place of `input.url`. Single-input recipe/probe/ffmpeg modes only; 5 GB per file; uploads are deleted after 3 days and are reusable across jobs until then. See https://xora.sh/docs/guides/direct-uploads ## Docs - [Introduction](https://xora.sh/docs/introduction): What Xora is, the job model, supported formats - [Quickstart](https://xora.sh/docs/quickstart): Zero to a completed job in under two minutes - [Authentication](https://xora.sh/docs/authentication): API keys and dashboard sessions - [Job lifecycle](https://xora.sh/docs/job-lifecycle): Every job state, progress, polling, retry - [Output formats](https://xora.sh/docs/output-formats): Which recipes produce which formats - [API reference](https://xora.sh/docs/api): Interactive OpenAPI explorer ## Recipes (mode: "recipe") - [Web ready](https://xora.sh/docs/recipes/web-ready): Probe first; remux or transcode only when needed for browser playback - [Transcode](https://xora.sh/docs/recipes/transcode): Convert between MP4, WebM, MOV, and audio formats - [Compress](https://xora.sh/docs/recipes/compress): Shrink file size with H.264 CRF control - [Resize](https://xora.sh/docs/recipes/resize): Scale with automatic aspect handling - [Trim](https://xora.sh/docs/recipes/trim): Cut a time range from video or audio - [Concat](https://xora.sh/docs/recipes/concat): Stream-copy merge of same-format files - [Extract audio](https://xora.sh/docs/recipes/extract-audio): Pull the audio track, stream-copy when possible - [Thumbnail](https://xora.sh/docs/recipes/thumbnail): Grab a frame as JPG or PNG - [Edit proxy](https://xora.sh/docs/recipes/proxy): Scrub-friendly low-res proxies for timeline editors - [Probe](https://xora.sh/docs/recipes/probe): Enriched ffprobe metadata on the job record ## Guides - [MCP server](https://xora.sh/docs/guides/mcp): Connect agents over the Model Context Protocol — tool-call the jobs API directly - [Custom FFmpeg args](https://xora.sh/docs/guides/ffmpeg): mode "ffmpeg" — raw argument arrays with {{in_1}}/{{out_1}} placeholders - [Direct uploads](https://xora.sh/docs/guides/direct-uploads): presigned PUT for local files, then input.uploadId instead of input.url - [Webhooks](https://xora.sh/docs/guides/webhooks): Terminal-state callbacks instead of polling - [Error handling](https://xora.sh/docs/guides/error-handling): Structured errors, rejected vs failed, retries - [Presets](https://xora.sh/docs/guides/presets): Save a job configuration, run it by presetId - [Storage providers (BYOB)](https://xora.sh/docs/guides/storage-providers): Deliver outputs to your own Cloudflare R2 bucket - [Input staging](https://xora.sh/docs/guides/input-staging): stageInput: false for byte-range reads from seekable origins ## Optional - [Full docs in one file](https://xora.sh/llms-full.txt): Every docs page above as plain Markdown - [Pricing](https://xora.sh/pricing/): Free tier (25 credits/mo, 2 GB stored media, 250 MB max input, no credit card); Build $15/mo (400 credits, 15 GB storage, 2 GB max input, BYOB); Scale $49/mo (1500 credits, 100 GB storage, 6 GB max input, BYOB). One meter: credits. 1 credit = 1 minute of processing work a job performs (probe, staging, encode, merge — metered per second); each GB of output the job produces adds 10 credits, counted once, same for managed storage or BYOB. Typical jobs: thumbnail ~0.1 credits, podcast hour to MP3 ~2, 10-min 1080p transcode ~12. Downloads are unlimited and free; inputs are never charged. Credits and storage are hard caps: when exhausted, new jobs return a LIMIT_EXCEEDED error (HTTP 402) until reset or upgrade. Jobs ending failed or rejected bill nothing. Every completed job returns cost.totalUsd on the job record. - [Blog](https://xora.sh/blog): Guides on FFmpeg workflows, n8n automation, encoding practices --- # Full docs --- # Introduction Source: https://xora.sh/docs/introduction/ Xora is a **programmatic video transcoding API**. Send a URL (or [upload a local file](https://xora.sh/docs/guides/direct-uploads/)), pick a recipe (or write your own FFmpeg command), and get processed media back — all through a single REST endpoint at **`https://api.xora.sh/v1`**. ## How it works ``` Your app Xora │ │ ├── POST /api/v1/jobs ─────────▶│ (input URL + recipe) │ │ │◀── 202 { jobId, state } ──────┤ (job accepted) │ │ │ ... processing ... │ │ │ ├── GET /api/v1/jobs/:id ──────▶│ (check status) │◀── { state, signedUrl } ──────┤ (download when ready) │ │ ``` 1. **Authenticate** — use an API key (`Authorization: Bearer nte_…`) or sign in to the dashboard. 2. **Create a job** — `POST /api/v1/jobs` with a recipe or custom FFmpeg args. 3. **Wait for completion** — poll the job status or receive a [webhook](https://xora.sh/docs/guides/webhooks/). 4. **Download** — grab your output from the signed URL, or have it delivered to [your own R2 bucket](https://xora.sh/docs/guides/storage-providers/). ## What you can do | Feature | Description | |---------|-------------| | **9 built-in recipes** | [Web ready](https://xora.sh/docs/recipes/web-ready/), [Edit proxy](https://xora.sh/docs/recipes/proxy/), [compress](https://xora.sh/docs/recipes/compress/), [transcode](https://xora.sh/docs/recipes/transcode/), [resize](https://xora.sh/docs/recipes/resize/), [trim](https://xora.sh/docs/recipes/trim/), [extract audio](https://xora.sh/docs/recipes/extract-audio/), [thumbnail](https://xora.sh/docs/recipes/thumbnail/), [concat](https://xora.sh/docs/recipes/concat/) | | **Probe jobs** | Inspect media with [`mode: "probe"`](https://xora.sh/docs/recipes/probe/) — enriched ffprobe metadata on the job record | | **Custom FFmpeg** | Pass your own [FFmpeg argument array](https://xora.sh/docs/guides/ffmpeg/) for full control | | **Direct uploads** | [Upload local files](https://xora.sh/docs/guides/direct-uploads/) with a presigned URL when there's nothing public to point at | | **Presets** | Save and reuse [job configurations](https://xora.sh/docs/guides/presets/) — run them with a single `presetId` | | **Input staging** | Optionally [skip copying inputs](https://xora.sh/docs/guides/input-staging/) when your URL supports HTTP byte ranges | | **Webhooks** | Get notified when jobs [complete or fail](https://xora.sh/docs/guides/webhooks/) | | **BYOB storage** | Route outputs to [your own Cloudflare R2 bucket](https://xora.sh/docs/guides/storage-providers/) | | **Files API** | [List, inspect, and delete](https://xora.sh/docs/api/) stored output files | ## Supported formats | Type | Formats | |------|---------| | Video | `mp4`, `webm`, `mov`, `gif` | | Audio | `mp3`, `aac`, `wav` | | Image | `jpg`, `png` | Probe jobs return metadata on the job record — see [Probe Media Metadata](https://xora.sh/docs/recipes/probe/). See [Output Formats](https://xora.sh/docs/output-formats/) for details on which recipes support which formats. ## Components | Piece | What it does | |-------|-------------| | **REST API** | `POST /api/v1/jobs`, `GET /api/v1/jobs/:id`, uploads, files, presets, tokens, usage | | **Dashboard** | Web UI at `/app` — manage presets, inspect jobs, create API keys | ## Next steps - [**Quickstart →**](https://xora.sh/docs/quickstart/) — create your first transcoding job in 2 minutes - [**Recipes →**](https://xora.sh/docs/recipes/web-ready/) — browse all 10 built-in recipes - [**API Reference →**](https://xora.sh/docs/api/) — interactive OpenAPI docs --- # Quickstart Source: https://xora.sh/docs/quickstart/ This guide takes you from zero to a completed transcoding job. You'll generate a thumbnail from a sample video using a single API call. ## Prerequisites - A Xora account — [sign up here](https://xora.sh/app/sign-up) (free) - An API key — create one in the [dashboard](https://xora.sh/app/dashboard/tokens) **Tip:** Your API key starts with `nte_` and is shown only once when created. Copy it somewhere safe. ## Your first job 1. **Create a thumbnail job** Send a `POST` to `/api/v1/jobs` with the `thumbnail` recipe. This captures a single frame from a video at the 1-second mark: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://samplelib.com/lib/preview/mp4/sample-5s.mp4" }, "output": { "format": "jpg" }, "recipe": { "name": "thumbnail", "seekSeconds": 1 } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4' }, output: { format: 'jpg' }, recipe: { name: 'thumbnail', seekSeconds: 1 } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://samplelib.com/lib/preview/mp4/sample-5s.mp4" }, "output": { "format": "jpg" }, "recipe": { "name": "thumbnail", "seekSeconds": 1 } } ) data = response.json() print(data) ``` You'll get a `202 Accepted` response with the job ID: ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "queued" } ``` 2. **Check job status** Poll the job to see its progress. Replace `JOB_ID` with the `jobId` from step 1: **cURL:** ```bash curl https://api.xora.sh/v1/jobs/JOB_ID \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs/JOB_ID', { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.get( "https://api.xora.sh/v1/jobs/JOB_ID", headers={ "Authorization": "Bearer YOUR_API_KEY" } ) data = response.json() print(data) ``` While processing, you'll see states like `probing`, `transcoding`, etc. with a `progress` percentage: ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "transcoding", "progress": 50 } ``` 3. **Download the output** When `state` is `"completed"`, the response includes a signed download URL: ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "completed", "progress": 100, "output": { "signedUrl": "https://cdn.xora.sh/...signed-url..." }, "output_files": { "output": { "filename": "output.jpg", "file_type": "image", "file_format": "jpg", "mime_type": "image/jpeg", "storage_url": "https://cdn.xora.sh/...signed-url..." } } } ``` Open the `signedUrl` in your browser or download it with curl: **cURL:** ```bash curl -o thumbnail.jpg "SIGNED_URL_HERE" ``` **Node.js:** ```javascript import fs from 'fs'; import { pipeline } from 'stream/promises'; const response = await fetch('SIGNED_URL_HERE'); if (!response.ok) throw new Error(`Unexpected response: ${response.statusText}`); await pipeline(response.body, fs.createWriteStream('thumbnail.jpg')); ``` **Python:** ```python import requests response = requests.get("SIGNED_URL_HERE") with open("thumbnail.jpg", "wb") as f: f.write(response.content) ``` ## Processing a local file The job above used a public sample URL. When your file only exists locally, request a presigned upload, `PUT` the bytes, then create the job against the returned `uploadId` instead of a URL: **cURL:** ```bash # 1. Request an upload (sizeBytes is checked against your plan limit) curl -X POST https://api.xora.sh/v1/uploads \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sizeBytes": 734003200, "filename": "my-video.mp4" }' # 2. Upload the bytes to the returned url (no auth header needed) curl -X PUT "PRESIGNED_URL_HERE" --upload-file my-video.mp4 # 3. Create the job with the uploadId 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": "UPLOAD_ID_HERE" }, "output": { "format": "jpg" }, "recipe": { "name": "thumbnail", "seekSeconds": 1 } }' ``` **Node.js:** ```javascript import { statSync, openAsBlob } from 'node:fs'; const path = 'my-video.mp4'; const auth = { Authorization: 'Bearer YOUR_API_KEY' }; const { uploadId, url } = await fetch('https://api.xora.sh/v1/uploads', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ sizeBytes: statSync(path).size, filename: path }), }).then((r) => r.json()); await fetch(url, { method: 'PUT', body: await openAsBlob(path) }); const job = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { uploadId }, output: { format: 'jpg' }, recipe: { name: 'thumbnail', seekSeconds: 1 }, }), }).then((r) => r.json()); ``` **Python:** ```python import os, requests path = "my-video.mp4" auth = {"Authorization": "Bearer YOUR_API_KEY"} upload = requests.post( "https://api.xora.sh/v1/uploads", headers=auth, json={"sizeBytes": os.path.getsize(path), "filename": path}, ).json() with open(path, "rb") as f: requests.put(upload["url"], data=f) job = requests.post( "https://api.xora.sh/v1/jobs", headers=auth, json={ "mode": "recipe", "input": {"uploadId": upload["uploadId"]}, "output": {"format": "jpg"}, "recipe": {"name": "thumbnail", "seekSeconds": 1}, }, ).json() ``` **Note:** Uploads are kept for 3 days and can be reused by multiple jobs in that window. See [Direct Uploads](https://xora.sh/docs/guides/direct-uploads/) for size limits and the full flow. ## Using a preset instead If you've saved a preset in the [dashboard](https://xora.sh/app/dashboard/presets), you can run a job with just its ID: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "presetId": "YOUR_PRESET_ID", "input": { "url": "https://example.com/video.mp4" } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ presetId: 'YOUR_PRESET_ID', input: { url: 'https://example.com/video.mp4' } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "presetId": "YOUR_PRESET_ID", "input": { "url": "https://example.com/video.mp4" } } ) data = response.json() print(data) ``` **Note:** Presets save your recipe/FFmpeg configuration so you don't have to repeat it every time. See the [Presets guide](https://xora.sh/docs/guides/presets/) for details. ## Receiving webhooks instead of polling Instead of polling, you can pass a `webhookUrl` to get notified when the job finishes: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "jpg" }, "recipe": { "name": "thumbnail", "seekSeconds": 1 }, "webhookUrl": "https://your-server.com/webhook" }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/video.mp4' }, output: { format: 'jpg' }, recipe: { name: 'thumbnail', seekSeconds: 1 }, webhookUrl: 'https://your-server.com/webhook' }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "jpg" }, "recipe": { "name": "thumbnail", "seekSeconds": 1 }, "webhookUrl": "https://your-server.com/webhook" } ) data = response.json() print(data) ``` Xora will `POST` the job result to your URL when it reaches a terminal state. See [Webhooks](https://xora.sh/docs/guides/webhooks/). ## What's next? - [**Recipes →**](https://xora.sh/docs/recipes/web-ready/) — web-ready video, edit proxy, compress, transcode, resize, trim, extract audio, thumbnails, concat - [**Probe metadata →**](https://xora.sh/docs/recipes/probe/) — inspect media with `mode: "probe"` - [**Custom FFmpeg →**](https://xora.sh/docs/guides/ffmpeg/) — full control with your own FFmpeg arguments - [**Direct Uploads →**](https://xora.sh/docs/guides/direct-uploads/) — process local files with no public URL - [**Presets →**](https://xora.sh/docs/guides/presets/) — save and reuse job configurations - [**Input Staging →**](https://xora.sh/docs/guides/input-staging/) — skip copying inputs when your URL supports byte ranges - [**Storage Providers →**](https://xora.sh/docs/guides/storage-providers/) — route outputs directly to your own Cloudflare R2 bucket - [**Job lifecycle →**](https://xora.sh/docs/job-lifecycle/) — understand job states and progress - [**API Reference →**](https://xora.sh/docs/api/) — full interactive API docs --- # Authentication Source: https://xora.sh/docs/authentication/ Every request to the Xora API must be authenticated. You can use an **API key** (for programmatic access) or a **Clerk session** (for the dashboard UI). ## API keys API keys start with `nte_` and are 52 characters long. You create them in the [dashboard](https://xora.sh/app/dashboard/tokens) and they're shown **only once** — copy the full key when it's displayed. ### Sending your key You can pass your API key in either of two headers: **cURL (Authorization):** ```bash curl https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer nte_your_api_key_here" ``` **cURL (X-API-KEY):** ```bash curl https://api.xora.sh/v1/jobs \ -H "X-API-KEY: nte_your_api_key_here" ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { headers: { 'Authorization': 'Bearer nte_your_api_key_here' } }); ``` **Python:** ```python import requests response = requests.get( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer nte_your_api_key_here" } ) ``` Both methods are equivalent. Use whichever fits your HTTP client best. ### Creating a key 1. Sign in at [xora.sh/app](https://xora.sh/app/sign-in) 2. Go to **Dashboard → Tokens** 3. Click **Create Token** and give it a name (e.g., "Production API") 4. Copy the full key immediately — it won't be shown again ### Revoking a key In **Dashboard → Tokens**, click the delete button next to any key. Once revoked, all requests using that key will receive a `401` response immediately. **Caution:** Revoking a key is instant and irreversible. Any running integrations using that key will stop working. ## Dashboard sessions The web dashboard at `/app` uses [Clerk](https://clerk.com) for authentication. When you interact with the dashboard, it calls `/api/v1/*` on **`xora.sh`** (same origin) using your Clerk session cookie — no API key needed. Programmatic clients should use **`https://api.xora.sh/v1`** with an API key. ## Error responses If authentication fails, the API returns a `401` status with this JSON body: ```json { "error": { "code": "UNAUTHORIZED", "message": "Authentication required. Provide a valid Bearer token or sign in via the dashboard.", "retryable": false } } ``` Common causes: - Missing `Authorization` or `X-API-KEY` header - Key doesn't start with `nte_` - Key has been revoked - Key was copy-pasted incorrectly (check for trailing spaces) ## CORS Browser requests are allowed from the configured dashboard origin (set via `XORA_PUBLIC_ORIGIN`). If CORS is not configured, all origins are allowed. The API accepts these headers in cross-origin requests: - `Authorization` - `Content-Type` - `X-API-KEY` ## Security best practices **Tip:** - **Never expose keys in client-side code.** Call the Xora API from your backend server, not from browser JavaScript. - **Use separate keys** for development, staging, and production. - **Rotate keys periodically.** Create a new key, update your integration, then revoke the old one. - **Name your keys** descriptively (e.g., "CI Pipeline", "Production Backend") so you know what to revoke if one leaks. --- # Job Lifecycle Source: https://xora.sh/docs/job-lifecycle/ Every transcoding job moves through a series of states from creation to completion (or failure). Understanding these states helps you build robust integrations. ## Job states When you create a job, it begins as `queued` and progresses through these states: ``` queued → probing → planning → transcoding → merging → completed ↘ (optional segmenting on legacy paths) ↘ failed ↘ rejected ↘ cancelled ``` | State | Description | |-------|-------------| | `queued` | Job accepted, waiting to be picked up | | `probing` | Analyzing the input file (codec, duration, resolution, byte-range support) | | `planning` | Determining the processing strategy | | `segmenting` | Legacy state — not used on current engine paths (inputs are range-read or staged once) | | `transcoding` | Actively processing media (longest phase) | | `merging` | Assembling or wrapping media streams into the final container file | | `completed` | Done — output is ready for download | | `failed` | Something went wrong — check `error` for details | | `rejected` | Input was rejected before processing (e.g., unsupported format or unsafe command) | | `cancelled` | You cancelled the job before it finished | ### Terminal states A job is **terminal** when it reaches one of: `completed`, `failed`, `rejected`, or `cancelled`. Terminal jobs cannot transition to another state (except via `retry`). ## Tracking progress The `progress` field is a number from `0` to `100` that updates as the job runs: ```json { "jobId": "01JXYZ...", "state": "transcoding", "progress": 65 } ``` - `0` — job is queued or just started - `1–99` — actively processing - `100` — completed **Note:** Progress updates are approximate. Short jobs may jump from 0 to 100. Longer jobs update more gradually as they process. ## Polling for completion The simplest way to track a job is to poll `GET /api/v1/jobs/:id` until the state is terminal: **cURL / Bash:** ```bash # Poll every 2 seconds while true; do RESPONSE=$(curl -s https://api.xora.sh/v1/jobs/JOB_ID \ -H "Authorization: Bearer YOUR_API_KEY") STATE=$(echo "$RESPONSE" | jq -r '.state') PROGRESS=$(echo "$RESPONSE" | jq -r '.progress') echo "State: $STATE, Progress: $PROGRESS%" if [ "$STATE" = "completed" ] || [ "$STATE" = "failed" ] || \ [ "$STATE" = "rejected" ] || [ "$STATE" = "cancelled" ]; then echo "$RESPONSE" | jq . break fi sleep 2 done ``` **Node.js:** ```javascript const pollJob = async (jobId) => { const terminalStates = ['completed', 'failed', 'rejected', 'cancelled']; while (true) { const response = await fetch(`https://api.xora.sh/v1/jobs/${jobId}`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(`State: ${data.state}, Progress: ${data.progress}%`); if (terminalStates.includes(data.state)) { console.log('Final job details:', data); break; } // Wait 2 seconds before polling again await new Promise(resolve => setTimeout(resolve, 2000)); } }; pollJob('JOB_ID'); ``` **Python:** ```python import time import requests def poll_job(job_id): terminal_states = {"completed", "failed", "rejected", "cancelled"} while True: response = requests.get( f"https://api.xora.sh/v1/jobs/{job_id}", headers={ "Authorization": "Bearer YOUR_API_KEY" } ) data = response.json() print(f"State: {data['state']}, Progress: {data['progress']}%") if data["state"] in terminal_states: print("Final job details:", data) break # Wait 2 seconds before polling again time.sleep(2) poll_job("JOB_ID") ``` **Tip:** For production use, consider [webhooks](https://xora.sh/docs/guides/webhooks/) instead of polling. They're more efficient and you get notified instantly. ## Using webhooks Instead of polling, pass a `webhookUrl` when creating a job. Xora will `POST` the result to your URL when the job reaches a terminal state: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress", "crf": 28 }, "webhookUrl": "https://your-server.com/xora-webhook" }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/video.mp4' }, output: { format: 'mp4' }, recipe: { name: 'compress', crf: 28 }, webhookUrl: 'https://your-server.com/xora-webhook' }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress", "crf": 28 }, "webhookUrl": "https://your-server.com/xora-webhook" } ) data = response.json() print(data) ``` See the [Webhooks guide](https://xora.sh/docs/guides/webhooks/) for payload format and configuration. ## Job actions ### Cancel a job Stop a running job before it completes: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs/JOB_ID/cancel \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs/JOB_ID/cancel', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs/JOB_ID/cancel", headers={ "Authorization": "Bearer YOUR_API_KEY" } ) data = response.json() print(data) ``` ```json { "cancelled": true } ``` **Note:** Cancellation is best-effort. If the job is already completing, it may finish before the cancel takes effect. ### Retry a failed job Retry a job that ended in `failed`, `rejected`, or `cancelled` state. The retry uses the same input parameters: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs/JOB_ID/retry \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs/JOB_ID/retry', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs/JOB_ID/retry", headers={ "Authorization": "Bearer YOUR_API_KEY" } ) data = response.json() print(data) ``` ```json { "retried": true, "jobId": "01JXYZ1234ABCDEF56789000" } ``` ### Delete job output Remove the hosted output files from storage. This is permanent: **cURL:** ```bash curl -X DELETE https://api.xora.sh/v1/jobs/JOB_ID/output \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs/JOB_ID/output', { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.delete( "https://api.xora.sh/v1/jobs/JOB_ID/output", headers={ "Authorization": "Bearer YOUR_API_KEY" } ) data = response.json() print(data) ``` ```json { "jobId": "01JXYZ1234ABCDEF56789000", "deleted": true } ``` **Caution:** Deleted outputs cannot be recovered. Download the files before deleting if you need to keep them. ## Listing jobs Fetch a paginated list of your jobs, optionally filtered by state: **cURL:** ```bash # List recent jobs curl "https://api.xora.sh/v1/jobs?limit=20&offset=0" \ -H "Authorization: Bearer YOUR_API_KEY" # Filter by state curl "https://api.xora.sh/v1/jobs?state=completed&limit=10" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Node.js:** ```javascript // List recent jobs const responseRecent = await fetch('https://api.xora.sh/v1/jobs?limit=20&offset=0', { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); console.log(await responseRecent.json()); // Filter by state const responseState = await fetch('https://api.xora.sh/v1/jobs?state=completed&limit=10', { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); console.log(await responseState.json()); ``` **Python:** ```python import requests # List recent jobs response_recent = requests.get( "https://api.xora.sh/v1/jobs?limit=20&offset=0", headers={"Authorization": "Bearer YOUR_API_KEY"} ) print(response_recent.json()) # Filter by state response_state = requests.get( "https://api.xora.sh/v1/jobs?state=completed&limit=10", headers={"Authorization": "Bearer YOUR_API_KEY"} ) print(response_state.json()) ``` ```json { "jobs": [ { "id": "01JXYZ...", "mode": "recipe", "recipe_name": "compress", "state": "completed", "created_at": "2026-06-04T10:00:00.000Z", "completed_at": "2026-06-04T10:00:45.000Z", "duration_seconds": 120.5 } ], "limit": 20, "offset": 0 } ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `limit` | number | `20` | Results per page (max 100) | | `offset` | number | `0` | Skip this many results | | `state` | string | — | Filter by state: `queued`, `probing`, `planning`, `segmenting`, `transcoding`, `merging`, `completed`, `failed`, `rejected`, `cancelled` | --- # Output Formats Source: https://xora.sh/docs/output-formats/ Xora supports 9 output formats across video, audio, and image transcoding jobs. You specify the format in your job's `output.format` field. For media inspection without transcoding, use [`mode: "probe"`](https://xora.sh/docs/recipes/probe/) — metadata is returned on the job record, not as a file. ## Supported formats | Format | Type | MIME Type | Description | |--------|------|-----------|-------------| | `mp4` | Video | `video/mp4` | H.264 video — universal playback, great for web | | `webm` | Video | `video/webm` | VP8/VP9 video — optimized for web browsers | | `mov` | Video | `video/quicktime` | QuickTime container — common in Apple/pro workflows | | `gif` | Image | `image/gif` | Animated GIF — for previews and social media | | `mp3` | Audio | `audio/mpeg` | MPEG audio — most compatible audio format | | `aac` | Audio | `audio/aac` | Advanced Audio Coding — better quality than MP3 at same bitrate | | `wav` | Audio | `audio/wav` | Uncompressed audio — lossless, large files | | `jpg` | Image | `image/jpeg` | JPEG image — for thumbnails and frame captures | | `png` | Image | `image/png` | PNG image — lossless, supports transparency | ## Which recipes support which formats | Recipe | Video formats | Audio formats | Image formats | |--------|--------------|---------------|---------------| | **webReady** | `mp4` | — | — | | **proxy** | `mp4` | — | — | | **compress** | `mp4`, `webm`, `mov` | — | — | | **transcode** | `mp4`, `webm`, `mov` | `mp3`, `aac`, `wav` | — | | **resize** | `mp4`, `webm`, `mov` | — | — | | **trim** | `mp4`, `webm`, `mov` | `mp3`, `aac`, `wav` | — | | **extractAudio** | — | `mp3`, `aac`, `wav` (fallback only) | — | | **thumbnail** | — | — | `jpg`, `png` | | **concat** | `mp4`, `webm`, `mov` | `mp3`, `aac`, `wav` | — | **Note:** Custom [FFmpeg jobs](https://xora.sh/docs/guides/ffmpeg/) can produce any of the 9 media formats. The table above applies to recipe mode only. ## Choosing a format ### Video - **`mp4`** — safest default. Plays everywhere (browsers, phones, desktop). Uses H.264 codec. - **`webm`** — smaller file sizes for the same quality. Best for web-only use. Not universally supported on older devices. - **`mov`** — use when your downstream workflow requires QuickTime (e.g., Final Cut Pro, After Effects). ### Audio - **`mp3`** — most compatible. Use for podcasts, music, or any general-purpose audio. - **`aac`** — higher quality than MP3 at the same bitrate. Default audio codec in MP4 containers. - **`wav`** — uncompressed, lossless audio. Use when quality matters more than file size (e.g., mastering, sound design). ### extractAudio (special case) For the [extractAudio](https://xora.sh/docs/recipes/extract-audio/) recipe, `output.format` is only the **transcode fallback** when the source audio cannot be stream-copied. Xora probes the input and may deliver a different extension without re-encoding: | Resolved output | When | |-----------------|------| | `.mp3`, `.aac`, `.wav`, `.opus`, `.flac`, `.ogg` | Source codec supports stream copy | | `output.format` (`mp3`, `aac`, or `wav`) | Source codec not copyable (e.g. AC-3, DTS) | The completed file extension comes from the engine plan, not necessarily from `output.format`. ### Image - **`jpg`** — smaller files, great for thumbnails and previews. Lossy compression. - **`png`** — lossless, supports transparency. Larger files but pixel-perfect quality. ## Setting the format The format is always specified in the `output` object: ```json { "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress", "crf": 28 } } ``` For FFmpeg multi-file jobs, the format is determined by the output filename extension: ```json { "mode": "ffmpeg", "input_files": { "in_1": "https://example.com/video.mp4" }, "output_files": { "out_1": "result.webm" }, "ffmpeg": { "args": ["-i", "{{in_1}}", "-c:v", "libvpx-vp9", "{{out_1}}"] } } ``` --- # Web Ready Video Source: https://xora.sh/docs/recipes/web-ready/ The `webReady` recipe optimizes videos for **web delivery** — browser playback, embeds, and social sharing — without obvious quality loss. Xora probes the source first, then picks the cheapest path that still meets your goals. ## How it decides | Source condition | Strategy | |------------------|----------| | H.264 video, AAC/MP3 audio, MP4/MOV container, reasonable bitrate, **and faststart already present** | `passthrough`: return your original input URL — no ffmpeg run | | Browser-compatible source, faststart missing, **no explicit encode settings** | `faststart_remux`: stream-copy and move MP4 metadata to the front | | Unsupported codec/container, high bitrate, **or explicit encode settings** (`crf`, `bitrateKbps`, `scaleDown`, `width`, `height`) | `web_encode`: H.264/AAC MP4 with fast-decode settings and one keyframe per second | **Note:** For timeline editing proxies (always re-encode, optional all-intra keyframes), use the [proxy recipe](https://xora.sh/docs/recipes/proxy/) instead. ## Basic usage **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/upload.mov" }, "output": { "format": "mp4" }, "recipe": { "name": "webReady" } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/upload.mov' }, output: { format: 'mp4' }, recipe: { name: 'webReady' } }) }); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": {"url": "https://example.com/upload.mov"}, "output": {"format": "mp4"}, "recipe": {"name": "webReady"} } ) ``` ## Smaller web delivery (720p) Setting encode options forces a re-encode even when the source is browser-compatible: ```json { "mode": "recipe", "input": { "url": "https://example.com/large-upload.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "webReady", "scaleDown": true, "height": 720, "crf": 26 } } ``` ## Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `name` | string | Yes | — | Must be `"webReady"` | | `scaleDown` | boolean | No | `false` | Downscale when re-encoding. Ignored on passthrough/remux paths. | | `width` | number | No | auto | Output width when scaling | | `height` | number | No | `720` | Output height when `scaleDown` is true | | `crf` | number | No | `26` | Quality when re-encoding (lower = higher quality) | | `bitrateKbps` | number | No | — | Target video bitrate; overrides CRF when set | | `startSeconds` | number | No | — | Optional start offset in seconds | | `durationSeconds` | number | No | — | Optional duration limit in seconds | ## Output format `webReady` requires MP4 output: ```json "output": { "format": "mp4" } ``` ## When to use webReady vs proxy vs compress | Use `webReady` when… | Use [proxy](https://xora.sh/docs/recipes/proxy/) when… | Use [compress](https://xora.sh/docs/recipes/compress/) when… | |----------------------|----------------------------------------|----------------------------------------------| | Web playback and sharing | Timeline editing and scrubbing | You always want a CRF-controlled re-encode | | Source may already be web-ready | You always want re-encode | No passthrough/remux shortcuts | | Optional smaller delivery via CRF/scale | Optional all-intra keyframes | Predictable encode every time | --- # Transcode Video Source: https://xora.sh/docs/recipes/transcode/ The `transcode` recipe converts media between formats. Use it to go from MOV to MP4, MP4 to WebM, or to extract audio from video into MP3/AAC/WAV. ## Basic usage Convert an MOV file to MP4: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/recording.mov" }, "output": { "format": "mp4" }, "recipe": { "name": "transcode" } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/recording.mov' }, output: { format: 'mp4' }, recipe: { name: 'transcode' } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://example.com/recording.mov" }, "output": { "format": "mp4" }, "recipe": { "name": "transcode" } } ) data = response.json() print(data) ``` ### Response ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "queued" } ``` ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Must be `"transcode"` | The transcode recipe takes no extra parameters — the output format in `output.format` determines the conversion target. ## Supported conversions | Input → Output | Example use case | |-----------------|-----------------| | MOV → `mp4` | iPhone recordings for web delivery | | MP4 → `webm` | Optimized web video | | WebM → `mp4` | Browser recording to universal format | | MP4 → `mp3` | Extract audio track as MP3 | | Video → `aac` | Extract audio as AAC | | Video → `wav` | Lossless audio extraction | ### Output formats | Format | Type | |--------|------| | `mp4` | Video | | `webm` | Video | | `mov` | Video | | `mp3` | Audio | | `aac` | Audio | | `wav` | Audio | **Tip:** If you only need the audio from a video, use the [extractAudio recipe](https://xora.sh/docs/recipes/extract-audio/) instead. It stream-copies compatible audio when possible and only transcodes when necessary. `transcode` always targets `output.format`. ## Examples ### MOV to MP4 ```json { "mode": "recipe", "input": { "url": "https://example.com/recording.mov" }, "output": { "format": "mp4" }, "recipe": { "name": "transcode" } } ``` ### MP4 to WebM ```json { "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "webm" }, "recipe": { "name": "transcode" } } ``` ### Video to MP3 (audio extraction) ```json { "mode": "recipe", "input": { "url": "https://example.com/interview.mp4" }, "output": { "format": "mp3" }, "recipe": { "name": "transcode" } } ``` **Note:** Transcoding always re-encodes the media. If you need to change the container without re-encoding (e.g., remux MKV → MP4), use a [custom FFmpeg job](https://xora.sh/docs/guides/ffmpeg/) with `-c copy`. --- # Compress Video Source: https://xora.sh/docs/recipes/compress/ The `compress` recipe re-encodes video with H.264 at a configurable quality level (CRF). This is the most common way to reduce file size for web delivery. ## Basic usage **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/raw-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress", "crf": 28 } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/raw-video.mp4' }, output: { format: 'mp4' }, recipe: { name: 'compress', crf: 28 } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://example.com/raw-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress", "crf": 28 } } ) data = response.json() print(data) ``` ### Response ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "queued" } ``` ## Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `name` | string | Yes | — | Must be `"compress"` | | `crf` | number | No | `23` | Constant Rate Factor — controls quality vs size tradeoff | ### Understanding CRF CRF (Constant Rate Factor) controls the quality-to-size tradeoff on a scale from **0** (lossless) to **51** (worst quality): | CRF | Quality | Typical use | |-----|---------|-------------| | 18 | Visually lossless | Archival, professional | | 23 | High quality | Default, general purpose | | 28 | Good quality | Web delivery, smaller files | | 32 | Medium quality | Previews, bandwidth-constrained | | 38+ | Low quality | Rough previews only | **Tip:** Start with **CRF 28** for web delivery. If quality isn't good enough, try 23. Each step of ~6 roughly halves/doubles the file size. ## Supported output formats | Format | Description | |--------|-------------| | `mp4` | H.264 in MP4 container — best compatibility | | `webm` | VP8/VP9 in WebM — smaller for web | | `mov` | H.264 in QuickTime — for Apple/pro workflows | ## Full example with polling **cURL / Bash:** ```bash # 1. Create the job JOB=$(curl -s -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/large-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress", "crf": 28 } }') JOB_ID=$(echo "$JOB" | jq -r '.jobId') echo "Created job: $JOB_ID" # 2. Poll until done while true; do STATUS=$(curl -s "https://api.xora.sh/v1/jobs/$JOB_ID" \ -H "Authorization: Bearer YOUR_API_KEY") STATE=$(echo "$STATUS" | jq -r '.state') echo "State: $STATE" [ "$STATE" = "completed" ] || [ "$STATE" = "failed" ] && break sleep 3 done # 3. Download URL=$(echo "$STATUS" | jq -r '.output.signedUrl') curl -o compressed.mp4 "$URL" ``` **Node.js:** ```javascript import fs from 'fs'; import { pipeline } from 'stream/promises'; // 1. Create the job const createResponse = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/large-video.mp4' }, output: { format: 'mp4' }, recipe: { name: 'compress', crf: 28 } }) }); const { jobId } = await createResponse.json(); console.log(`Created job: ${jobId}`); // 2. Poll until done let status; while (true) { const statusResponse = await fetch(`https://api.xora.sh/v1/jobs/${jobId}`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); status = await statusResponse.json(); console.log(`State: ${status.state}`); if (status.state === 'completed' || status.state === 'failed') break; await new Promise(resolve => setTimeout(resolve, 3000)); } // 3. Download if (status.state === 'completed') { const downloadResponse = await fetch(status.output.signedUrl); if (!downloadResponse.ok) throw new Error(`Unexpected response: ${downloadResponse.statusText}`); await pipeline(downloadResponse.body, fs.createWriteStream('compressed.mp4')); console.log('File downloaded successfully.'); } else { console.error('Job failed:', status.error); } ``` **Python:** ```python import time import requests # 1. Create the job create_response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": {"url": "https://example.com/large-video.mp4"}, "output": {"format": "mp4"}, "recipe": {"name": "compress", "crf": 28} } ) job_id = create_response.json()["jobId"] print(f"Created job: {job_id}") # 2. Poll until done status = None while True: status_response = requests.get( f"https://api.xora.sh/v1/jobs/{job_id}", headers={"Authorization": "Bearer YOUR_API_KEY"} ) status = status_response.json() print(f"State: {status['state']}") if status["state"] in ["completed", "failed"]: break time.sleep(3) # 3. Download if status["state"] == "completed": download_response = requests.get(status["output"]["signedUrl"]) with open("compressed.mp4", "wb") as f: f.write(download_response.content) print("File downloaded successfully.") else: print("Job failed:", status.get("error")) ``` **Note:** Compression doesn't change the resolution or crop the video. If you also want to resize, use the [resize recipe](https://xora.sh/docs/recipes/resize/) instead, or chain jobs. --- # Resize Video Source: https://xora.sh/docs/recipes/resize/ The `resize` recipe scales video to a target width and height. Specify one or both dimensions — if you only set one, the other scales proportionally to preserve aspect ratio. ## Basic usage Resize to 1280×720 (HD): **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/4k-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "resize", "width": 1280, "height": 720 } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/4k-video.mp4' }, output: { format: 'mp4' }, recipe: { name: 'resize', width: 1280, height: 720 } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://example.com/4k-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "resize", "width": 1280, "height": 720 } } ) data = response.json() print(data) ``` ### Response ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "queued" } ``` ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Must be `"resize"` | | `width` | number | No* | Target width in pixels | | `height` | number | No* | Target height in pixels | *At least one of `width` or `height` is needed to resize. ### Aspect ratio behavior | Provided | Behavior | |----------|----------| | Both `width` and `height` | Scales to exact dimensions | | Only `width` | Height auto-calculated to preserve aspect ratio | | Only `height` | Width auto-calculated to preserve aspect ratio | ## Common presets | Name | Width | Height | Aspect | |------|-------|--------|--------| | 4K UHD | 3840 | 2160 | 16:9 | | 1080p Full HD | 1920 | 1080 | 16:9 | | 720p HD | 1280 | 720 | 16:9 | | 480p SD | 854 | 480 | 16:9 | | Instagram Square | 1080 | 1080 | 1:1 | | Instagram Story | 1080 | 1920 | 9:16 | ## Examples ### Scale to width, auto height Only set `width` to scale proportionally: ```json { "mode": "recipe", "input": { "url": "https://example.com/4k-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "resize", "width": 1280 } } ``` A 3840×2160 input becomes 1280×720 (maintains 16:9). ### Scale for Instagram Story (9:16 vertical) ```json { "mode": "recipe", "input": { "url": "https://example.com/landscape.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "resize", "width": 1080, "height": 1920 } } ``` **Note:** Setting exact dimensions that don't match the source aspect ratio will stretch the video. For more control (padding, cropping), use a [custom FFmpeg job](https://xora.sh/docs/guides/ffmpeg/) with the `pad` or `crop` filter. ### Output as WebM ```json { "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "webm" }, "recipe": { "name": "resize", "width": 854, "height": 480 } } ``` ## Supported output formats | Format | Description | |--------|-------------| | `mp4` | H.264 in MP4 container | | `webm` | VP8/VP9 in WebM container | | `mov` | H.264 in QuickTime container | --- # Trim / Clip Source: https://xora.sh/docs/recipes/trim/ The `trim` recipe cuts a segment from your media at a specified start time and duration. Use it to extract highlights, remove intros, or clip a specific scene. ## Basic usage Extract 30 seconds starting at the 1-minute mark: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/long-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "trim", "startSeconds": 60, "durationSeconds": 30 } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/long-video.mp4' }, output: { format: 'mp4' }, recipe: { name: 'trim', startSeconds: 60, durationSeconds: 30 } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://example.com/long-video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "trim", "startSeconds": 60, "durationSeconds": 30 } } ) data = response.json() print(data) ``` ### Response ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "queued" } ``` ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Must be `"trim"` | | `startSeconds` | number | No | Start time in seconds (default: `0`) | | `durationSeconds` | number | No | Duration of the clip in seconds | **Tip:** Decimal values are supported for frame-accurate timing. For example, `startSeconds: 60.5` starts at 1 minute and 500 milliseconds. ## Examples ### Extract the first 10 seconds ```json { "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "trim", "durationSeconds": 10 } } ``` ### Skip the first 5 seconds (remove intro) ```json { "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "trim", "startSeconds": 5 } } ``` This produces a clip from 5 seconds to the end of the video. ### Extract a highlight from the middle ```json { "mode": "recipe", "input": { "url": "https://example.com/webinar.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "trim", "startSeconds": 300, "durationSeconds": 120 } } ``` Extracts a 2-minute clip starting at the 5-minute mark. ### Trim audio Trimming works on audio files too: ```json { "mode": "recipe", "input": { "url": "https://example.com/podcast.mp3" }, "output": { "format": "mp3" }, "recipe": { "name": "trim", "startSeconds": 0, "durationSeconds": 60 } } ``` ## Supported output formats | Format | Type | |--------|------| | `mp4` | Video | | `webm` | Video | | `mov` | Video | | `mp3` | Audio | | `aac` | Audio | | `wav` | Audio | **Note:** If `startSeconds + durationSeconds` exceeds the input duration, the output stops at the end of the file — no error is thrown. --- # Concat / Merge Files Source: https://xora.sh/docs/recipes/concat/ The `concat` recipe merges two or more media files into a single output using FFmpeg's concat demuxer with stream copy (`-c copy`). This is fast because it doesn't re-encode — it just joins the files end to end. **Caution:** All input files **must share the same codec, sample rate, and format**. For example, you can't concat an MP3 and a WAV directly — convert them to the same format first. ## Basic usage Merge two MP3 files: **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input_files": { "in_1": "https://example.com/part1.mp3", "in_2": "https://example.com/part2.mp3" }, "output": { "format": "mp3" }, "recipe": { "name": "concat" } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input_files: { in_1: 'https://example.com/part1.mp3', in_2: 'https://example.com/part2.mp3' }, output: { format: 'mp3' }, recipe: { name: 'concat' } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input_files": { "in_1": "https://example.com/part1.mp3", "in_2": "https://example.com/part2.mp3" }, "output": { "format": "mp3" }, "recipe": { "name": "concat" } } ) data = response.json() print(data) ``` ### Response ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "queued" } ``` ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Must be `"concat"` | ## Important: `input_files`, not `input` The concat recipe uses `input_files` instead of `input`. This is the **only recipe** that works this way: | Key | Pattern | Example | |-----|---------|---------| | `in_1` | First file | `"https://example.com/part1.mp4"` | | `in_2` | Second file | `"https://example.com/part2.mp4"` | | `in_3` | Third file (optional) | `"https://example.com/part3.mp4"` | | … | More files | As many as needed | Keys must follow the pattern `in_` followed by letters, numbers, or underscores. You need **at least two** entries. **Note:** Do **not** include an `input` field when using concat. Use `input_files` only. The API will return a validation error if you provide both. ## Examples ### Merge video segments Join two MP4 clips: ```json { "mode": "recipe", "input_files": { "in_1": "https://example.com/intro.mp4", "in_2": "https://example.com/main.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "concat" } } ``` ### Merge three audio files ```json { "mode": "recipe", "input_files": { "in_1": "https://example.com/chapter1.mp3", "in_2": "https://example.com/chapter2.mp3", "in_3": "https://example.com/chapter3.mp3" }, "output": { "format": "mp3" }, "recipe": { "name": "concat" } } ``` ### Merge many files You can merge as many files as needed: ```json { "mode": "recipe", "input_files": { "in_part_a": "https://example.com/a.mp4", "in_part_b": "https://example.com/b.mp4", "in_part_c": "https://example.com/c.mp4", "in_part_d": "https://example.com/d.mp4", "in_part_e": "https://example.com/e.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "concat" } } ``` ## Supported output formats | Format | Type | |--------|------| | `mp4` | Video | | `webm` | Video | | `mov` | Video | | `mp3` | Audio | | `aac` | Audio | | `wav` | Audio | ## How it works The concat recipe uses FFmpeg's **concat demuxer** with stream copy (`-c copy`). This means: - **No re-encoding** — files are joined as-is, preserving original quality - **Very fast** — processing time is minimal regardless of file size - **Inputs must be compatible** — same codec, sample rate, and format ## Troubleshooting | Problem | Cause | Fix | |---------|-------|-----| | "codec mismatch" error | Input files use different codecs | Transcode all inputs to the same format first | | Audio out of sync | Different sample rates | Ensure all audio tracks use the same sample rate | | Only 1 `input_files` entry | Need at least 2 | Add a second file | | Used `input` instead of `input_files` | Wrong field | Remove `input`, use `input_files` with `in_1`, `in_2`, etc. | --- # Extract Audio Source: https://xora.sh/docs/recipes/extract-audio/ The `extractAudio` recipe pulls the first audio track from a video (or audio-in-video container) and saves it as a standalone file. Xora **probes the input** and chooses the best path: 1. **Stream copy** when the source codec maps to a web-friendly file extension (fast, no quality loss). 2. **Transcode** to your `output.format` fallback when stream copy is not possible (for example AC-3, DTS, or E-AC-3). **Note:** `output.format` is a **fallback**, not a forced output type. If the input audio is AAC and you pass `"format": "mp3"`, you still get `.aac` via stream copy — not a re-encoded MP3. ## How output format is chosen After probing, Xora resolves the output file extension: | Source audio codec | Output file | Method | |-------------------|-------------|--------| | `mp3` | `.mp3` | Stream copy | | `aac` | `.aac` | Stream copy | | `opus` | `.opus` | Stream copy | | `vorbis` | `.ogg` | Stream copy | | `flac` | `.flac` | Stream copy | | `pcm_*` (for example `pcm_s16le`) | `.wav` | Stream copy | | Anything else (for example `ac3`, `dts`, `eac3`) | `.mp3`, `.aac`, or `.wav` | Transcode to `output.format` | The default output key is `outputs/{jobId}/result.{ext}` where `{ext}` is the **resolved** extension above — not always the extension from `output.format`. ### Fallback transcode settings When transcoding is required, `output.format` must be one of `mp3`, `aac`, or `wav`: | `output.format` | Encoder | Notes | |-----------------|---------|-------| | `mp3` (default if invalid) | `libmp3lame` | Best universal compatibility | | `aac` | AAC @ 192 kbps | Smaller than MP3 at similar quality | | `wav` | 16-bit PCM | Lossless, large files | **Caution:** Jobs are **rejected** if the input has no audio stream (`UNSUPPORTED_JOB`). ## Basic usage Request MP3 as the fallback (used only when stream copy is not possible): **cURL:** ```bash curl -X POST https://api.xora.sh/v1/jobs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "recipe", "input": { "url": "https://example.com/interview.mp4" }, "output": { "format": "mp3" }, "recipe": { "name": "extractAudio" } }' ``` **Node.js:** ```javascript const response = await fetch('https://api.xora.sh/v1/jobs', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'recipe', input: { url: 'https://example.com/interview.mp4' }, output: { format: 'mp3' }, recipe: { name: 'extractAudio' } }) }); const data = await response.json(); console.log(data); ``` **Python:** ```python import requests response = requests.post( "https://api.xora.sh/v1/jobs", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "mode": "recipe", "input": { "url": "https://example.com/interview.mp4" }, "output": { "format": "mp3" }, "recipe": { "name": "extractAudio" } } ) data = response.json() print(data) ``` ### Response ```json { "jobId": "01JXYZ1234ABCDEF56789000", "state": "queued" } ``` When the job completes, check the output file metadata (extension and MIME type) — it reflects the resolved format, which may differ from `output.format`. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Must be `"extractAudio"` | | `startSeconds` | number | No | Start offset into the input (seconds) | | `durationSeconds` | number | No | Maximum duration to extract (seconds) | | `stageInput` | boolean | No | Set `false` to stream directly from your URL when the host supports HTTP byte ranges (skips copying the full video to engine storage). Default: `true`. See [Input Staging](https://xora.sh/docs/guides/input-staging/). | | Job field | Type | Required | Description | |-----------|------|----------|-------------| | `output.format` | string | Yes | Fallback transcode target: `mp3`, `aac`, or `wav`. Ignored when stream copy applies. | **Tip:** For long videos on range-capable CDNs, add `"stageInput": false` to cut job time roughly in half (no full-file staging step). ## Examples ### AAC in MP4 → `.aac` (stream copy) Input has AAC audio; `output.format` is only used if copy is impossible: ```json { "mode": "recipe", "input": { "url": "https://example.com/video.mp4" }, "output": { "format": "mp3" }, "recipe": { "name": "extractAudio" } } ``` Completed output: `result.aac` (not `result.mp3`). ### AC-3 in MKV → `.mp3` (transcode) Unsupported for stream copy; falls back to `output.format`: ```json { "mode": "recipe", "input": { "url": "https://example.com/movie.mkv" }, "output": { "format": "mp3" }, "recipe": { "name": "extractAudio" } } ``` Completed output: `result.mp3`. ### Fast extract (skip staging) ```json { "mode": "recipe", "input": { "url": "https://cdn.example.com/video.mp4" }, "output": { "format": "mp3" }, "recipe": { "name": "extractAudio" }, "stageInput": false } ``` ### Transcode fallback to AAC Use when you know the source needs re-encoding and you prefer AAC over MP3: ```json { "mode": "recipe", "input": { "url": "https://example.com/video.mkv" }, "output": { "format": "aac" }, "recipe": { "name": "extractAudio" } } ``` ### Custom `outputPath` If you set `outputPath`, the file extension must match `output.format` at request time. The engine still resolves copy vs transcode from the source; prefer the default output key when you want the resolved extension (for example `.aac` from an AAC source). ## Browser and player compatibility Stream-copied formats vary in HTML5 `