Quickstart
With a test key you can try the whole flow right now, for free. Nothing is charged: the conversion is completed after about 20 seconds, and its webhook follows within a minute.
- Start a conversion. Send a link to your video, or the sandbox test link below. You get a conversion id back straight away.
curl https://api.xclusivevr.com/v1/conversions \
-H "Authorization: Bearer xvr_test_..." \
-H "Content-Type: application/json" \
-d '{ "source": "https://sandbox.xclusivevr.com/trailer.mp4", "output": "sbs" }'- Check the status, or wait for your webhook. When
statusiscompleted, the response holds the download links inoutputs.
curl https://api.xclusivevr.com/v1/conversions/conv_8f2kq1Lx0aZb3YtQwE1r \
-H "Authorization: Bearer xvr_test_..."- Go live. Swap the test key for your live key and send the link to your own video. The calls stay exactly the same.
Authentication
Send your key in the Authorization header of every request:
Authorization: Bearer xvr_live_...xvr_live_…converts for real and uses minutes from your balance.xvr_test_…uses the sandbox: free, and nothing is converted.- Live and test are separate worlds: a live key never sees test conversions, and the other way round.
You create your keys yourself on your account page, once you have added a payment card there (nothing is charged): a sandbox key right away, and a live key once you have topped up or received your test minutes. Adding a card also gives you 10 free test minutes with your own videos, once per company and per card. A key is shown only once. Keep keys on your server, never in an app, a browser or a public repository. Lost a key or think it leaked? Revoke it on your account page and create a new one.
Sandbox
Build and test your integration without spending minutes. Every call works the same as in live mode, including webhooks and safe retries.
- A conversion goes from
queuedtoprocessingand iscompletedafter about 20 seconds.progresscounts up as it would for real. - Your request is checked exactly as in live mode, with the same error codes. A source URL must be a public https address:
https://localhost/…, a private IP or a host that does not exist gives400 invalid_source. We never download it. - No video at hand? Use
https://sandbox.xclusivevr.com/trailer.mp4, or any other path on that host withoutfailin it: it always completes. The host does not exist; the sandbox only recognises it. POST /v1/uploadsgives a sandboxupload_id(it starts withsandbox/). Nothing is uploaded and itsupload_urlaccepts no files: create the conversion with theupload_idstraight away. Any otherupload_idends asfailedwithupload_not_found, just like an upload that never arrived in live mode.- The links in
outputspoint to real sample files you can download: an MP4 forsbsand a MOV forspatial. They are not a conversion of your video. With"stream": true,playerplays a demo video.
Test a failure
Send one of these as source with a test key. The conversion ends as failed with that error.code and the same message as in live mode. Failures while fetching the video come straight from queued, after a few seconds; conversion_failed comes after processing, at the end.
| Source URL | error.code |
|---|---|
https://sandbox.xclusivevr.com/fail/source_unreachable | source_unreachable |
https://sandbox.xclusivevr.com/fail/source_redirect | source_redirect |
https://sandbox.xclusivevr.com/fail/source_too_large | source_too_large |
https://sandbox.xclusivevr.com/fail/unreadable_video | unreadable_video |
https://sandbox.xclusivevr.com/fail/no_video_stream | no_video_stream |
https://sandbox.xclusivevr.com/fail/video_too_long | video_too_long |
https://sandbox.xclusivevr.com/fail/source_not_360 | source_not_360 |
https://sandbox.xclusivevr.com/fail/insufficient_balance | insufficient_balance |
https://sandbox.xclusivevr.com/fail/daily_limit_reached | daily_limit_reached |
https://sandbox.xclusivevr.com/fail/import_timeout | import_timeout |
https://sandbox.xclusivevr.com/fail/processing_error | processing_error |
https://sandbox.xclusivevr.com/fail/conversion_failed | conversion_failed |
For upload_not_found, send an upload_id you made up. Any other source URL with fail in it, for example https://example.com/fail.mp4, ends with sandbox_failure. In live mode none of this is special, and sandbox.xclusivevr.com fails the URL check.
Create a conversion
POST/v1/conversions
Starts a conversion and answers right away with 202 Accepted. We fetch your video, measure its length and start converting.
| Field | Type | Description |
|---|---|---|
source | string | An https link to your video. Must be publicly reachable, without redirects. Give either source or upload_id. |
upload_id | string | The id from POST /v1/uploads, if you uploaded the file yourself. |
output | string or array | "sbs" (full side-by-side, for Meta Quest, other VR headsets and most players), "spatial" (Apple Vision Pro) or both: ["sbs", "spatial"]. Default "sbs". Full side-by-side is included in your price per minute; spatial adds €0.10 per minute, or the equivalent in your account currency (see prices_per_minute.spatial_surcharge in GET /v1/balance). |
format | string | "2d" for regular video (default), "180" for VR180, "360" for 360° video. A 360° source must be exactly twice as wide as it is tall. 360° is delivered as one file that plays in every headset: use output "sbs". |
depth | string | Optional. How strong the 3D depth is. "comfort": gentle, easy on the eyes for long viewing; "cinema": the default, natural depth like a 3D film; "aggressive": the strongest pop-out effect. Default "cinema". |
webhook | string | Optional. An https URL we call when the conversion is completed or failed. Without it we use the default webhook of your account, if you have one. |
stream | boolean | Optional. true = with hosting (+€0.10 per minute, or the equivalent in your account currency): we host the 3D video for 12 months and you get a player to embed and a stream link. Needs "sbs" in output. Default false (files to download only). |
destination | boolean or object | Optional. Deliver the 3D files to your own storage: true for the folder of your connection, { "path": "series/s02e01" } for a subfolder, false for not. Without it, your account setting applies. See Deliver to your own storage. |
title | string | Optional. Your own name for the video in your dashboard, up to 150 characters. Without it we use the file name. |
metadata | object | Optional. Your own data: an object (not a list), up to 2 KB, returned unchanged on the conversion and in every webhook. Field names may not start with $ or contain a dot. Add "batch" (for example "Season 2") to group videos on your account page, with "3 of 24 ready". |
curl https://api.xclusivevr.com/v1/conversions \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: trailer-042" \
-d '{
"source": "https://cdn.example.com/trailer.mp4",
"output": ["sbs", "spatial"],
"format": "2d",
"webhook": "https://example.com/hooks/xvr",
"stream": true,
"metadata": { "catalog_id": "A-1042", "batch": "Season 2" }
}'HTTP/1.1 202 Accepted
{
"id": "conv_8f2kq1Lx0aZb3YtQwE1r",
"object": "conversion",
"mode": "live",
"status": "queued",
"output": ["sbs", "spatial"],
"format": "2d",
"source": { "url": "https://cdn.example.com/trailer.mp4" },
"stream": true,
"duration_seconds": null,
"minutes": 0,
"price_tier": null,
"charged": { "amount": 0, "currency": "EUR", "test_minutes": 0, "refunded": false },
"progress": 0,
"error": null,
"metadata": { "catalog_id": "A-1042", "batch": "Season 2" },
"created_at": "2026-10-10T11:48:00.000Z",
"completed_at": null
}Upload your own file
Is your video not online? Upload it straight to our storage in three steps.
POST/v1/uploads
curl https://api.xclusivevr.com/v1/uploads \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-d '{ "filename": "trailer.mp4", "content_type": "video/mp4", "size": 734003200 }'HTTP/1.1 201 Created
{
"upload_id": "api/66f1.../1791633600000_trailer.mp4",
"upload_url": "https://...",
"method": "PUT",
"expires_in": 3600
}curl -X PUT "<upload_url>" \
-H "Content-Type: video/mp4" \
--upload-file trailer.mp4Upload the file once. A second PUT to the same URL replaces the file. We read the video itself, so the Content-Type you send is not checked.
curl https://api.xclusivevr.com/v1/conversions \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-d '{ "upload_id": "api/66f1.../1791633600000_trailer.mp4", "output": "sbs" }'With a test key nothing is uploaded: you get a sandbox upload_id to convert straight away. See Sandbox.
Formats
Send the video you have. We read almost every common file, so there is no need to convert it first.
What you can send
| Supported | |
|---|---|
| Files | MP4 (recommended), MOV, M4V, MKV, WebM, AVI, WMV, ASF, MPG, MPEG, VOB, MTS, M2TS, 3GP and FLV |
| Video inside | H.264, H.265 (HEVC), AV1, VP9, VP8, Apple ProRes, MPEG-2, Xvid/DivX and Windows Media Video |
| Resolution | Up to 8K |
| Frame rate | Any, including slow motion at 120 or 240 fps |
| HDR | HDR10, HLG and Dolby Vision (we keep the HDR10 base layer) |
| 180° and 360° | Set format to "180" or "360". A 360° video is twice as wide as it is tall, for example 3840×1920, and comes back as side-by-side only. |
What you get back
| Full side-by-side ("sbs") | Spatial video ("spatial") | |
|---|---|---|
| File | MP4 | MOV |
| Plays on | Meta Quest, Pico, Samsung Galaxy XR and every 3D player | Apple Vision Pro; in 2D on any other screen |
| Video | H.265 (HEVC), left and right eye side by side, each at full resolution | MV-HEVC: both eyes in one video |
| Resolution | Your own, up to 4K per eye. An 8K source comes back at 4K per eye. | Your own, up to 4K per eye |
| Frame rate | Your own. Above 60 fps comes back at 60 fps, the highest headsets play smoothly. | Same as side-by-side |
| HDR | Stays HDR: 10-bit with your original colours | Stays HDR in the file; the Photos app on Vision Pro still shows spatial video in SDR |
| Sound | Your original sound | Your original sound |
The file sizes depend on your video. Both outputs are made for watching in a headset, at a quality that holds up close to your eyes.
The conversion object
| Field | Description |
|---|---|
id | Unique id, starts with conv_. |
mode | "live" or "test". |
status | queued → processing → completed, or failed. |
progress | 0 to 100. Never goes down; 100 only when completed. null when failed. |
output, format | What you asked for. |
duration_seconds | The length of your video, as we measured it. null until measured. |
stream | true when the video is hosted. |
minutes | The billable length in minutes: per second, at least 0.5. |
price_tier | "up_to_1080p" or "above_1080p": which price per minute applied, from the resolution we measured. null until the video has been measured. |
charged | { amount, currency, test_minutes, refunded }: what this conversion cost, from your credit and your test minutes. 0 after a refund. |
error | When failed: { code, message }. See Errors. |
title | The name you gave, or null. |
metadata | Your own data, unchanged. |
created_at, completed_at | ISO 8601 timestamps, UTC. |
outputs | When completed: a download link per output (sbs, spatial). Each link works for 7 days; fetch the conversion again for fresh links. |
expires_at | When the links in outputs stop working. |
available_until | Until when the files can be downloaded: 30 days after completion (14 days for a hosted video made entirely with test minutes). After that they are deleted. |
player | With hosting, when completed: { embed_url, iframe }. See Embed the player. |
stream_url | With hosting, when completed: a link to the streaming version for your own player or app. Works for 7 days; fetch the conversion again for a fresh one. |
hosted_until | With hosting: until when we host the video and the player plays it. Moves a year on with every renewal. |
hosting | With hosting: { auto_renew, renewals }. Whether the hosting renews automatically after each year, and how often it has. |
removed | Only present (true) when we took the video offline after a report under section 7.4 of the API Terms. No player and no links then; we email you why. |
destination | With your own storage: { path }. Otherwise null. |
delivery | With your own storage: { status, files, error, attempts, delivered_at }. status is pending, delivering, delivered or failed; files holds the key per output in your bucket. |
{
"id": "conv_8f2kq1Lx0aZb3YtQwE1r",
"object": "conversion",
"mode": "live",
"status": "completed",
"output": ["sbs", "spatial"],
"format": "2d",
"stream": true,
"duration_seconds": 222.4,
"minutes": 3.7067,
"price_tier": "above_1080p",
"charged": { "amount": 2.22, "currency": "EUR", "test_minutes": 0, "refunded": false },
"progress": 100,
"error": null,
"completed_at": "2026-10-10T12:01:12.000Z",
"outputs": {
"sbs": "https://.../trailer_3D_sbs.mp4?X-Amz-...",
"spatial": "https://.../trailer_3D_spatial.mov?X-Amz-..."
},
"expires_at": "2026-10-17T12:05:40.000Z",
"available_until": "2026-11-09T12:01:12.000Z",
"player": {
"embed_url": "https://www.xclusivevr.com/embed/k7Qx2aPm",
"iframe": "<iframe src="https://www.xclusivevr.com/embed/k7Qx2aPm" ...></iframe>"
},
"stream_url": "https://.../trailer_stream.mp4?X-Amz-...",
"hosted_until": "2027-10-10T12:01:12.000Z"
}How long it takes. Full side-by-side is about as fast as real time: a 10-minute video is usually ready in 10 to 15 minutes. 360° takes longer. Spatial video for Apple Vision Pro is ready within 24 hours.
Retrieve and list
GET/v1/conversions/:id
The current state of one conversion, with fresh download links when it is completed. Polling every 10 to 30 seconds is fine; webhooks save you the trouble.
GET/v1/conversions?limit=20
Your most recent conversions, newest first. limit is 1 to 100, default 20. The list has status and progress but no download links: fetch a single conversion for those.
curl "https://api.xclusivevr.com/v1/conversions?limit=50" \
-H "Authorization: Bearer xvr_live_..."
# { "data": [ { "id": "conv_...", "status": "processing", "progress": 42, ... }, ... ] }DELETE/v1/conversions/:id
Deletes a conversion right away: the 3D files, the source you uploaded, the streaming version, the player and every playback link stop working at once. Returns { "id": "conv_…", "object": "conversion", "deleted": true } and, if you have a webhook, sends conversion.deleted. Only a completed or failed conversion can be deleted; while it is still running you get 409 conversion_in_progress. Afterwards GET /v1/conversions/:id returns 404. Deleting cannot be undone and is not refunded. You can also delete a video on your account page.
Deliver to your own storage
Keep the files in your own bucket: we put the 3D files straight into your storage when the conversion is done. Any S3-compatible storage works: Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, MinIO and others. It costs nothing extra.
- On your account page, under Your own storage, enter your bucket, an optional folder and an access key. Use a key that can only write to this bucket. We save the connection only after a small test file (
xclusivevr-connection-test.txt) arrives. - Send
destinationwith a conversion, or tick Deliver every conversion here on your account page.
curl https://api.xclusivevr.com/v1/conversions \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-d '{
"source": "https://cdn.example.com/s02e01.mp4",
"output": ["sbs", "spatial"],
"destination": { "path": "series/s02e01" }
}'Files are named after your source file: <folder>/<path>/<name>_3D_sbs.mp4 and _3D_spatial.mov. We check the size of every file after writing it.
"destination": { "path": "series/s02e01" },
"delivery": {
"status": "delivered",
"files": {
"sbs": "xclusivevr/series/s02e01/s02e01_3D_sbs.mp4",
"spatial": "xclusivevr/series/s02e01/s02e01_3D_spatial.mov"
},
"error": null,
"attempts": 0,
"delivered_at": "2026-10-15T14:02:11.000Z"
}conversion.completedwaits until the files are in your bucket, and then includesdelivery.- If writing fails, we try again after 5 and after 30 minutes. After that
delivery.statusisfailedwith the reason, and your download links inoutputskeep working. - Fixed the cause, for example a new key? Deliver again with
POST /v1/conversions/:id/deliver, optionally with a newpath. You then getconversion.deliveredorconversion.delivery_failed. - Without hosting, we delete our copy 30 days after delivery. With
"stream": truewe keep hosting it as usual. - In the sandbox nothing is written:
deliveryshows where the files would go, with"sandbox": true. Use Test again on your account page to check the connection itself. - Your secret is stored encrypted and never shown again, not even to us in our admin.
POST/v1/conversions/:id/deliver
Delivers a completed conversion (again) to your storage. Body: optional { "path": "…" }. Answers 202 with the conversion; 409 while a delivery is running.
Embed the player
With "stream": true a completed conversion has a player. Paste the iframe into your page, like a YouTube or Vimeo video. Your viewers watch in 3D in a Meta Quest or Pico (tap Enter VR) and as a regular video on a computer or phone. No app and no download.
<iframe src="https://www.xclusivevr.com/embed/k7Qx2aPm"
width="960" height="540" style="border:0;max-width:100%"
allow="autoplay; fullscreen; xr-spatial-tracking" allowfullscreen></iframe>- Keep the
allowattribute: withoutxr-spatial-trackingthe headset cannot start VR. - Want the player to work only on your own websites, or only for viewers you allow? See Playback protection.
- Using your own player or app? Use
stream_urlinstead. - Hosting lasts 12 months after completion (
hosted_until). A video made with your test minutes is hosted for 14 days. - 25,000 watched minutes a month in the embedded player are included, for every account (also with Player Pro). Beyond that €1.50 per 1,000 minutes (or the equivalent in your account currency), taken from your balance after the month: 100,000 watched minutes in a month costs €112.50 extra.
GET /v1/balanceshows this month's total. - In the sandbox,
playerpoints to a demo video, so you can try the embed right away. - Player Pro: on a Meta Quest, Enter VR opens our cinema and environments instead of a floating screen, with the same embed code. Choose where viewers start with
env(see Player settings). On other devices the regular player plays. Player Pro is a monthly subscription (€199 excl. VAT, or the equivalent in your account currency) that you start, cancel and pay on your account page; it switches on as soon as you have paid. It also lets you add your logo and brand colour and hide “Powered by Xclusive VR” (Settings → Player Pro → Your brand); this applies straight away to all your hosted videos.
Player settings
Add settings to the embed URL, like you would with YouTube or Vimeo. Leave them out for the defaults.
https://www.xclusivevr.com/embed/k7Qx2aPm?autoplay=1&loop=1&t=1m30s&lang=de| Setting | Default | What it does |
|---|---|---|
autoplay=1 | off | Starts playing as soon as the player is visible. Browsers only allow this without sound, so the video starts muted. On a Meta Quest the viewer still taps once, which takes them straight into VR. |
muted=1 | off | Keeps the sound off, also when the viewer enters VR. The viewer can still turn it on. |
loop=1 | off | Plays the video again when it ends. |
t=90 | 0 | Starts at this point. Seconds (90), or 1m30s, or 01:30. |
controls=0 | on | Hides the control bar and the big play button, so you can build your own controls (see Control the player). Tapping the video still plays and pauses; in VR the viewer keeps the controls. |
lang=de | the viewer's language | Language of the buttons and messages: en, de, es, pt or fr. |
player_id=hero | none | Sent back with every message, so you can tell several players on one page apart. |
env=beach | your account setting | Player Pro: the environment a Meta Quest viewer starts in: cinema, lounge, hotel, beach, lake, lifesize or void. The viewer can still switch inside the headset. |
Control the player
Your page and the player talk with postMessage, the same way as the Vimeo and YouTube players. The player tells your page what happens, and your page can start, pause and seek.
<iframe id="xvr" src="https://www.xclusivevr.com/embed/k7Qx2aPm?controls=0&player_id=hero"
allow="autoplay; fullscreen; xr-spatial-tracking" allowfullscreen></iframe>
<button id="play">Play</button> <button id="pause">Pause</button>
<script>
const ORIGIN = "https://www.xclusivevr.com";
const player = document.getElementById("xvr").contentWindow;
const send = (method, value) => player.postMessage({ method, value }, ORIGIN);
window.addEventListener("message", (e) => {
if (e.origin !== ORIGIN || e.data?.source !== "xclusivevr-player") return;
const { event, data, method, value } = e.data;
if (event === "ready") console.log("Ready, duration", data.duration);
if (event === "timeupdate") progressBar.value = data.percent;
if (event === "ended") showNextVideo();
if (method === "getCurrentTime") console.log("Now at", value, "s");
});
document.getElementById("play").onclick = () => send("play");
document.getElementById("pause").onclick = () => send("pause");
</script>Events the player sends
Every message looks like { source: "xclusivevr-player", event, data, player_id }. Always check e.origin first.
| Event | data |
|---|---|
ready | { seconds, duration }: the video is loaded and the player accepts commands. |
play · pause · ended | { seconds, duration } |
timeupdate | { seconds, duration, percent }, up to four times a second while playing. |
seeked | { seconds, duration } |
volumechange | { volume, muted } |
enterxr · exitxr | The viewer put on the headset in VR, or left VR. |
fullscreenchange | { fullscreen } |
error | { message }, and { method } when a command failed. |
Commands your page can send
Send { method, value } as an object or as a JSON string. Wait for ready first.
| Method | value | What it does |
|---|---|---|
play | — | Starts playing. Without a tap from the viewer, browsers only allow this muted; otherwise you get an error event. |
pause | — | Pauses. |
seek | seconds | Jumps to this point. |
setVolume | 0 to 1 | Sets the volume. |
setMuted | true or false | Turns the sound off or on. |
getCurrentTime · getDuration · getPaused · getMuted · getVolume · getState | — | The player answers with { source, method, value, player_id }. getState gives everything at once. |
Entering VR always needs a tap from the viewer: browsers do not let a page start a headset session on its own.
Playback protection
Decide who can play your hosted videos. Set it in your dashboard (Settings → Playback protection) or with the API below. It applies to your whole account and straight away to every hosted video, including the ones already converted.
- Websites. With
allowed_domainsthe player only plays when it is embedded on those sites. Someone who copies your embed code cannot use it on their own site. Subdomains are included. An empty list means every website. - Signed links. For private videos, such as paid courses or internal training. With
signed_links_requiredthe player only plays with a link your server requests for each viewer. The link expires on its own, so a forwarded link stops working. Turn this on only once your site requests links, because embed codes without one stop playing straight away. - Spike alerts. We check every hour. When a video is suddenly watched far more than in a normal hour, we email you, at most once per video per day. Sensitivity
lowis 10 times a normal hour and at least 3,000 minutes,normal5 times and at least 1,000 minutes,high3 times and at least 300 minutes.
curl -X PATCH https://api.xclusivevr.com/v1/playback-settings \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-d '{
"allowed_domains": ["yourwebsite.com"],
"signed_links_required": true,
"spike_alerts": { "enabled": true, "sensitivity": "normal" }
}'Send only what you want to change. GET /v1/playback-settings returns the same object. These settings apply to your live videos, so changing them needs a live key; a test key gets 403 live_key_required.
{
"allowed_domains": ["yourwebsite.com"],
"plays_everywhere": false,
"signed_links_required": true,
"spike_alerts": { "enabled": true, "sensitivity": "normal" }
}Request a signed link
Call this from your server when a viewer opens the page, and put the returned iframe in the page instead of the normal one. expires_in is in seconds, from 60 to 604,800 (7 days); the default is 3600. Player settings still work: add them with &, for example &autoplay=1. Never put your API key in the browser.
curl -X POST https://api.xclusivevr.com/v1/conversions/conv_8f2kq1Lx0aZb3YtQwE1r/playback-links \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-d '{ "expires_in": 3600 }'{
"url": "https://www.xclusivevr.com/embed/k7Qx2aPm?token=mh3k9q0.Zp4...",
"iframe": "<iframe src="https://www.xclusivevr.com/embed/k7Qx2aPm?token=mh3k9q0.Zp4..." ...></iframe>",
"expires_at": "2027-10-01T11:00:00.000Z"
}- Without a valid link, or after it expires, the player shows a short neutral message and sends an
errorevent (see Control the player). Reloading your page gives the viewer a fresh link. - In the sandbox you get a link to the demo video, so you can build the whole flow before going live.
Sign links on your own server
Many viewers at once, for example when a course starts? Then sign the links yourself, without calling us for every viewer. Create a signing key once; your server then makes a short token (a JWT, RS256) for each viewer and adds it to the embed URL as ?token=. There is no API call per viewer, so no request limit. This works the same as with Mux or Cloudflare Stream.
curl -X POST https://api.xclusivevr.com/v1/signing-keys \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Production" }'
{
"id": "xvrsk_Qm3x9aPz0LkT5vRw",
"name": "Production",
"created_at": "2026-10-24T10:00:00.000Z",
"last_used_at": null,
"revoked_at": null,
"private_key": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t...",
"private_key_pem": "-----BEGIN PRIVATE KEY-----\n...",
"note": "The private key is shown only once. Store it on your server; we keep only the public key."
}- Store
private_key(base64) andidon your server, for example asXVR_SIGNING_KEYandXVR_SIGNING_KEY_ID. We show the private key only once and keep only the public key, so not even we can make links with it. - Signing keys sign links for your live videos, so creating and revoking them needs a live key. Up to 5 active keys; more gives
409 too_many_signing_keys. You can also create and revoke them in your dashboard. GET /v1/signing-keyslists your keys (never the private key) withlast_used_at.DELETE /v1/signing-keys/:idrevokes one: links signed with it stop playing straight away.
// npm install jsonwebtoken — on your server, for each viewer:
import jwt from "jsonwebtoken";
const privateKey = Buffer.from(process.env.XVR_SIGNING_KEY, "base64").toString();
const token = jwt.sign(
{ sub: "conv_8f2kq1Lx0aZb3YtQwE1r", aud: "playback", exp: Math.floor(Date.now() / 1000) + 3600 },
privateKey,
{ algorithm: "RS256", keyid: process.env.XVR_SIGNING_KEY_ID },
);
// player.embed_url of the conversion, plus the token:
const src = `https://www.xclusivevr.com/embed/k7Qx2aPm?token=${token}`;| Token field | Value |
|---|---|
kid (header) | The id of your signing key, xvrsk_… |
alg (header) | RS256 |
sub | The id of the conversion, conv_… |
aud | playback |
exp | Unix time when the link stops working. Required, and at most one year ahead. A few hours is usual. |
The player accepts both kinds of links: the ones from POST …/playback-links and the ones you sign yourself. A token only works for the conversion in sub, with a key of your own account that is not revoked.
Webhooks
We call your webhook URL with a POST when a conversion is finished:
| Event | When |
|---|---|
conversion.completed | The 3D files are ready. data.outputs holds the download links. |
conversion.failed | The conversion failed. data.error says why. Minutes already charged have been refunded. |
conversion.delivered | Only after POST /v1/conversions/:id/deliver: the files are now in your own storage. |
conversion.delivery_failed | Only after POST /v1/conversions/:id/deliver: delivering to your own storage failed again. data.delivery.error says why. |
conversion.deleted | You deleted the conversion, with DELETE /v1/conversions/:id or on your account page. data is { id, object, deleted: true }. |
test.ping | Only when you press "Send test event" on your account page. data.message holds a short text; use it to check your endpoint and signature. |
POST /hooks/xvr HTTP/1.1
Content-Type: application/json
User-Agent: XclusiveVR-Webhooks/1.0
X-Xvr-Event: conversion.completed
X-Xvr-Event-Id: evt_6702a9c1f0e4d2b8a1c3e5f7
X-Xvr-Signature: t=1791633672,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_6702a9c1f0e4d2b8a1c3e5f7",
"type": "conversion.completed",
"created_at": "2026-10-10T12:01:12.000Z",
"data": { "id": "conv_8f2kq1Lx0aZb3YtQwE1r", "status": "completed", "outputs": { ... }, ... }
}Check the signature
Every webhook is signed with your webhook secret (whsec_…, which you find on your account page under API keys). X-Xvr-Signature holds a timestamp t and v1, an HMAC-SHA256 of "<t>.<raw body>" with your secret, in hex. Compute it yourself and compare. Also check that t is recent, so an old message cannot be replayed.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a string), before any JSON parsing.
function verifySignature(rawBody, header, secret, toleranceSec = 300) {
const parts = header.split(",").map((p) => p.split("="));
const t = Number((parts.find(([k]) => k === "t") || [])[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
// While you rotate the secret, the header carries two v1 values: accept a match on any of them.
return parts.filter(([k]) => k === "v1").some(([, given]) => given?.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given)));
}
// Express: keep the raw body for this route.
app.post("/hooks/xvr", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifySignature(raw, req.get("X-Xvr-Signature") || "", process.env.XVR_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
res.sendStatus(200); // answer first, then do the work
handleEvent(event); // skip events whose id you already handled
});import hashlib, hmac, time
def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = int(next((v for k, v in parts if k == "t"), "0"))
if abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# While you rotate the secret, the header carries two v1 values: accept a match on any of them.
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")Answer quickly, handle once
- Answer with any
2xxwithin 10 seconds, then do the work. Anything else, a timeout or a redirect counts as not delivered. - We try again after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours: 8 attempts over about 45 hours.
- The same event can arrive more than once. Use
X-Xvr-Event-Idto skip events you already handled. - Your webhook URL must be https on port 443. We do not follow redirects.
Safe retries
Did the connection drop before you got an answer? Send the request again with the same Idempotency-Key header. You get the original answer back, and there is still one conversion and one charge.
curl https://api.xclusivevr.com/v1/conversions \
-H "Authorization: Bearer xvr_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c1e2a-7d0b-4c55-9a4e-3f1d2b6c8e90" \
-d '{ "source": "https://cdn.example.com/trailer.mp4" }'
# Same key + same body within 24 hours → the original response, header Idempotent-Replayed: true
# Same key + a different body → 409 idempotency_key_reused
# An error (400, 402, 429, 5xx) is not remembered: fix it and retry with the same key- Use a unique value per conversion, for example a UUID or your own id for the video. Up to 255 characters.
- A key is remembered for 24 hours, per account, and separately for test and live keys. The order of the fields in the body does not matter.
- Still being handled? You get
409 request_in_progress: try again a moment later. - Only a successful answer is remembered. After any error (for example
400,402,429or a5xx) the key is released, so after fixing the request or topping up, retrying with the same key really tries again.
Balance and billing
GET/v1/balance
curl https://api.xclusivevr.com/v1/balance -H "Authorization: Bearer xvr_live_..."
{
"mode": "live",
"balance": { "amount": 412.35, "currency": "EUR" },
"test_minutes": 0,
"prices_per_minute": { "convert": 0.4, "convert_up_to_1080p": 0.25, "stream": 0.5, "stream_up_to_1080p": 0.35, "spatial_surcharge": 0.1, "hosting_renewal_per_year": 0.08, "currency": "EUR" },
"daily_limit_minutes": 240,
"watched_minutes_this_month": 1840,
"included_watched_minutes": 25000,
"top_up_url": "https://www.xclusivevr.com/business/api/login"
}- Your balance is prepaid credit in the currency of your account (EUR, USD, GBP, CHF, CAD, AUD, SEK, NOK or DKK), chosen when you sign up and fixed after your first payment. Every amount in the API is in that currency; see
currencyinGET /v1/balance. A conversion costs its length in minutes times your price per minute. The price depends on the resolution of your video: up to 1080p (the short side is 1080 pixels or less, so a vertical 1080×1920 video counts too) or above. Hosting and the player add the same amount to both, and so does spatial video. Seeprices_per_minuteinGET /v1/balance. We measure the length ourselves, per second, with a minimum of 30 seconds. Regular, 180° or 360°, 30 or 60 frames per second: the same price. - Prices per minute, excluding VAT (in euros; other currencies have the equivalent): €0.25 up to 1080p and €0.40 above 1080p. Hosting and the player add €0.10, and so does spatial video for Apple Vision Pro. Full side-by-side is included. Every top-up and every Player Pro month comes with an invoice that shows the VAT, and VAT is reverse-charged within the EU with a valid VAT number.
- Test minutes are used first, before your credit.
- The cost is taken when the conversion starts. If it fails, the money and test minutes go back automatically.
- Hosting after the first year. Hosting (
"stream": true) includes 12 months. After that we renew it every year atprices_per_minute.hosting_renewal_per_yearper video minute (€0.08, or €0.05 with Player Pro; the equivalent in your account currency), taken from your balance the day before the end date. We email you 30 days before, with the videos and the amount. Turn renewal off per video on your account page or withPOST /v1/conversions/:id/hosting { "auto_renew": false }; the video then goes offline on its end date. Videos made with test minutes are never renewed. - Watched minutes above the 25,000 included each month are taken from your balance once the month is over, at €1.50 per 1,000 minutes (or the equivalent in your account currency). We email you the amount. If your balance is too low for that, it goes below zero. With auto top-up on, we top up straight away. Otherwise new conversions wait until you top up, and we email you a date 14 days later. Still below zero on that date? Your hosted videos pause until you top up; viewers see that the video is temporarily unavailable, and nothing is deleted.
- Balance too low for a video? The conversion fails with
insufficient_balanceand nothing is charged. With no credit and no test minutes, a new request gets402straight away. - Accounts you create yourself start with a daily limit of 240 video minutes and 2 conversions running at the same time; we raise both for you on request. The daily limit resets at 00:00 UTC; the current value is
daily_limit_minutesinGET /v1/balance. - Top up in your dashboard (
top_up_urlis its login page; we email you a code): pay by payment card and the credit is added straight away. Any amount from €100 (or the minimum for your account currency). Every top-up comes with an invoice, by email and in the dashboard.
Errors
Errors always come back as JSON with a stable code for your code and a message for people.
HTTP/1.1 400 Bad Request
{
"error": {
"code": "invalid_source",
"message": "source must be an https URL."
}
}When you make the request
| HTTP | Code | Meaning |
|---|---|---|
400 | invalid_json · invalid_source · invalid_output · invalid_format · invalid_depth · invalid_stream · invalid_webhook · invalid_metadata · invalid_title · invalid_upload_id · invalid_idempotency_key · invalid_destination | POST /v1/conversions: something in the request is not right; the message says what. metadata is an object (not a list) of at most 2 KB. |
400 | invalid_field_name | A field name starts with $ or contains a dot, also inside metadata. Rename it; we do not remove it silently. |
400 | invalid_size · file_too_large | POST /v1/uploads: size is not a positive number of bytes, or the file is larger than 5 GB (use a source URL or the dashboard for files up to 150 GB). |
400 | invalid_expires_in · not_hosted | POST …/playback-links: expires_in out of range, or the conversion has no hosting and so no player. |
400 | invalid_name | POST /v1/signing-keys: name must be a string of at most 100 characters. |
400 | invalid_auto_renew · not_hosted | POST …/hosting: auto_renew must be true or false, and only hosted conversions can renew. |
400 | invalid_domain · too_many_domains · invalid_signed_links_required · invalid_spike_alerts · invalid_sensitivity | PATCH /v1/playback-settings: a value is not valid; the message says which. |
400 | storage_not_connected | You sent destination, but no storage is connected on your account page. |
401 | unauthorized · key_expired | No key, an unknown key, a revoked key, or a key past its expiry date (create a new one on your account page). |
402 | insufficient_balance | Your balance is empty. |
403 | account_suspended | Your account is suspended. Contact us. |
403 | live_key_required | PATCH /v1/playback-settings, or creating or revoking a signing key, with a test key. These apply to your live videos. |
404 | not_found | No such route, or no conversion with this id in this mode. |
409 | idempotency_key_reused · request_in_progress | See Safe retries. |
409 | not_completed · delivery_in_progress | POST …/deliver: the conversion is not completed yet, or a delivery is running. |
409 | not_ready | POST …/playback-links: the conversion is not finished yet, or its player is not available. |
409 | too_many_signing_keys | POST /v1/signing-keys: you already have 5 active signing keys. Revoke one first. |
413 | request_too_large | The request body is too large. |
415 | unsupported_media_type | Send the body as UTF-8 JSON with Content-Type: application/json. |
429 | rate_limited | More than 120 requests a minute with this key. Wait and retry. |
429 | too_many_active_conversions | Your account already has the maximum number of conversions running (2 for self-service accounts). Retry when one is ready; see Retry-After. |
429 | daily_limit_reached | Your daily limit of video minutes is used up. Retry after 00:00 UTC; see Retry-After. |
500 | internal_error | Something went wrong on our side. Retry with the same Idempotency-Key. |
When a conversion fails
These codes appear in error.code of a failed conversion. This list is complete: we never put an internal error code here.
| Code | Meaning |
|---|---|
source_unreachable | We could not download the source URL. |
source_redirect | The source URL redirects. Give the final URL. |
source_too_large | The file is larger than 150 GB. |
upload_not_found | No file was uploaded for this upload_id. |
unreadable_video | We could not read this video file. |
no_video_stream | The file contains no video. |
video_too_long | Longer than 30 minutes (or the limit agreed for your account). Nothing was charged. For longer videos, contact customer support. |
source_not_360 | format is "360" but the file is not twice as wide as it is tall (equirectangular). |
insufficient_balance | Not enough minutes for this video. Nothing was charged. |
daily_limit_reached | Your daily limit is reached. |
import_timeout | Fetching the source took too long. |
conversion_failed | The conversion itself failed. Your minutes have been refunded. |
invalid_source | The source URL is not allowed (for example an internal address) when we fetched it. |
processing_error | Something went wrong on our side while starting the conversion. If anything was charged, it is refunded automatically. Try again or contact us with the conversion id. |
sandbox_failure | Test only: the source URL contained "fail". |
Limits and retention
| What | Limit |
|---|---|
Requests | 120 a minute per key |
Conversions at the same time | 2 for self-service accounts; more on request |
Video minutes a day | 240 for self-service accounts (resets at 00:00 UTC); more on request |
Video length | Up to 30 minutes. Longer videos by agreement: contact customer support. |
Frame rate | Any; the 3D video is delivered at up to 60 frames per second |
File size | Up to 150 GB from a source URL or the dashboard; up to 5 GB through upload_url |
Resolution | Up to 8K in; the 3D video comes back at up to 4K per eye |
Download links | 7 days per link; fetch the conversion for fresh ones |
Converted files | Downloadable for 30 days after completion |
Hosted video (with hosting) | 12 months after completion, then renewed every year automatically at prices_per_minute.hosting_renewal_per_year (lower with Player Pro) unless you turn it off; 14 days when made with test minutes |
Watched minutes | 25,000 a month included for every account, then €1.50 per 1,000 minutes (or the equivalent in your account currency) |
Your source file | Deleted within 7 days, also when you uploaded it but never converted it |
Need longer storage, more speed or a higher limit? Talk to us.
Questions while you build?
You talk to the team that built it. Send your question, with the conversion id if it is about a conversion.