Storage Providers (BYOB)
By default, Xora stores transcoding outputs in its own managed storage and gives you signed download URLs. With Bring Your Own Bucket (BYOB), you can route outputs directly to your Cloudflare R2 bucket.
How it works
Section titled “How it works”Without BYOB: With BYOB:Video → Xora → Xora storage Video → Xora → Your R2 bucket ↓ ↓ Signed URL (expires) Your bucket (permanent)With BYOB, outputs land in your R2 bucket under a prefix you configure. You own the files and manage access yourself.
Setting up BYOB
Section titled “Setting up BYOB”-
Create an R2 bucket
In your Cloudflare dashboard, create an R2 bucket. Note your Account ID (shown in the sidebar) and bucket name.
-
Create R2 API credentials
Go to R2 → Manage R2 API Tokens → Create API Token:
- Permission: Object Read & Write
- Scope: your bucket
- Copy the Access Key ID and Secret Access Key
-
Add the storage provider in Xora
In the Xora dashboard, go to Storage Providers → Add Provider, or use the API:
Terminal window curl -X POST https://api.xora.sh/v1/settings/storage-providers \-H "Authorization: Bearer YOUR_API_KEY" \-H "Content-Type: application/json" \-d '{"name": "My R2 Bucket","provider": "r2","accountId": "your-cloudflare-account-id","bucket": "my-xora-outputs","prefix": "transcoded/","accessKeyId": "your-r2-access-key","secretAccessKey": "your-r2-secret-key","makeDefault": true}'const response = await fetch('https://api.xora.sh/v1/settings/storage-providers', {method: 'POST',headers: {'Authorization': 'Bearer YOUR_API_KEY','Content-Type': 'application/json'},body: JSON.stringify({name: 'My R2 Bucket',provider: 'r2',accountId: 'your-cloudflare-account-id',bucket: 'my-xora-outputs',prefix: 'transcoded/',accessKeyId: 'your-r2-access-key',secretAccessKey: 'your-r2-secret-key',makeDefault: true})});const data = await response.json();console.log(data);import requestsresponse = requests.post("https://api.xora.sh/v1/settings/storage-providers",headers={"Authorization": "Bearer YOUR_API_KEY","Content-Type": "application/json"},json={"name": "My R2 Bucket","provider": "r2","accountId": "your-cloudflare-account-id","bucket": "my-xora-outputs","prefix": "transcoded/","accessKeyId": "your-r2-access-key","secretAccessKey": "your-r2-secret-key","makeDefault": True})data = response.json()print(data) -
Test the connection
Terminal window curl -X POST https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID/test \-H "Authorization: Bearer YOUR_API_KEY"const response = await fetch('https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID/test', {method: 'POST',headers: {'Authorization': 'Bearer YOUR_API_KEY'}});const data = await response.json();console.log(data);import requestsresponse = requests.post("https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID/test",headers={"Authorization": "Bearer YOUR_API_KEY"})data = response.json()print(data){ "ok": true }
The test writes a small file, verifies it exists, then deletes it. If something is wrong (bad credentials, wrong bucket name), you’ll get an error message explaining the issue.
Configuration fields
Section titled “Configuration fields”| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | A label for this provider (e.g., “Production R2”) |
provider | string | Yes | Must be "r2" |
accountId | string | * | Your Cloudflare account ID |
endpoint | string | * | Custom S3-compatible endpoint URL |
bucket | string | Yes | R2 bucket name |
prefix | string | No | Object key prefix (e.g., "media/") — trailing slash added automatically |
accessKeyId | string | Yes | R2 API token access key ID |
secretAccessKey | string | Yes | R2 API token secret access key |
makeDefault | boolean | No | Set as the default storage destination |
*Provide either accountId or endpoint. If you provide accountId, the endpoint is auto-generated as https://{accountId}.r2.cloudflarestorage.com.
Setting a default provider
Section titled “Setting a default provider”When you set a provider as default (makeDefault: true), all new jobs automatically route their outputs to that bucket. You can change the default at any time:
curl -X PUT https://api.xora.sh/v1/settings \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "defaultStorageProviderId": "01KSTORAGE1234ABCDEF" }'const response = await fetch('https://api.xora.sh/v1/settings', { method: 'PUT', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ defaultStorageProviderId: '01KSTORAGE1234ABCDEF' })});const data = await response.json();console.log(data);import requests
response = requests.put( "https://api.xora.sh/v1/settings", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "defaultStorageProviderId": "01KSTORAGE1234ABCDEF" })data = response.json()print(data)Set to null to go back to Xora-hosted storage:
{ "defaultStorageProviderId": null }Managing providers
Section titled “Managing providers”List all providers
Section titled “List all providers”curl https://api.xora.sh/v1/settings/storage-providers \ -H "Authorization: Bearer YOUR_API_KEY"const response = await fetch('https://api.xora.sh/v1/settings/storage-providers', { headers: { 'Authorization': 'Bearer YOUR_API_KEY' }});const data = await response.json();console.log(data);import requests
response = requests.get( "https://api.xora.sh/v1/settings/storage-providers", headers={ "Authorization": "Bearer YOUR_API_KEY" })data = response.json()print(data){ "providers": [ { "id": "01KSTORAGE1234ABCDEF", "name": "My R2 Bucket", "type": "s3_compatible", "provider": "r2", "endpoint": "https://abc123.r2.cloudflarestorage.com", "bucket": "my-xora-outputs", "prefix": "transcoded/", "status": "active", "isDefault": true, "createdAt": "2026-06-01T10:00:00.000Z" } ], "defaultStorageProviderId": "01KSTORAGE1234ABCDEF"}Update a provider
Section titled “Update a provider”curl -X PUT https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Updated Name", "provider": "r2", "accountId": "your-cloudflare-account-id", "bucket": "new-bucket-name", "accessKeyId": "your-r2-access-key" }'const response = await fetch('https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID', { method: 'PUT', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Updated Name', provider: 'r2', accountId: 'your-cloudflare-account-id', bucket: 'new-bucket-name', accessKeyId: 'your-r2-access-key' })});const data = await response.json();console.log(data);import requests
response = requests.put( "https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "name": "Updated Name", "provider": "r2", "accountId": "your-cloudflare-account-id", "bucket": "new-bucket-name", "accessKeyId": "your-r2-access-key" })data = response.json()print(data)Delete a provider
Section titled “Delete a provider”curl -X DELETE https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID \ -H "Authorization: Bearer YOUR_API_KEY"const response = await fetch('https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID', { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }});const data = await response.json();console.log(data);import requests
response = requests.delete( "https://api.xora.sh/v1/settings/storage-providers/PROVIDER_ID", headers={ "Authorization": "Bearer YOUR_API_KEY" })data = response.json()print(data)If the deleted provider was your default, the default is automatically cleared (reverts to Xora-hosted storage).
Provider status
Section titled “Provider status”| Status | Meaning |
|---|---|
pending | Just created, not yet tested |
active | Connection test passed |
error | Last test failed — check lastError for details |
Custom Output Path
Section titled “Custom Output Path”When creating a job, you can optionally override the default output path (outputs/<jobId>/result.<format>) inside your bucket using the outputPath parameter on the job creation request or in the dashboard New job form.
Requirements & Validation
Section titled “Requirements & Validation”- Custom storage default: This option is only supported when a default custom storage provider is configured.
- Allowed characters:
a-zA-Z0-9_-/.(alphanumeric, hyphens, underscores, dots, and forward slashes). No path traversal (../) or leading/trailing slashes are allowed. - Extension match: The extension in the
outputPathmust exactly match the job’s output format (e.g.path/to/my-video.mp4for formatmp4). - Scope restriction: Not supported on multi-file
ffmpegmode jobs.
Example Request
Section titled “Example Request”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.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress" }, "outputPath": "processed/2026/june/final-compress.mp4" }'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.mp4' }, output: { format: 'mp4' }, recipe: { name: 'compress' }, outputPath: 'processed/2026/june/final-compress.mp4' })});const data = await response.json();console.log(data);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.mp4" }, "output": { "format": "mp4" }, "recipe": { "name": "compress" }, "outputPath": "processed/2026/june/final-compress.mp4" })data = response.json()print(data)If your storage prefix is configured as transcoded/, the output will be saved at the key transcoded/processed/2026/june/final-compress.mp4 in your R2 bucket.
Security
Section titled “Security”- Your
secretAccessKeyis encrypted at rest using AES and never returned in API responses - Credentials are only decrypted at job time to upload outputs to your bucket
- R2 API tokens should be scoped to the minimum required permission (Object Read & Write on a single bucket)