Logotipo de Xclusive VR
  • Precios
  • Ejemplos
  • Convertir
  • Blog
  • Para empresas
  • Ayuda
API overview and pricing

Documentation · v1

API documentation

Send a video, get 3D back. Everything you need to integrate: one base URL, a key, JSON in and JSON out.

Import the OpenAPI spec into Postman or Insomnia to try every call, or generate a client for your language.

Base URLhttps://api.xclusivevr.com/v1

OpenAPI spec (openapi.json)

Contents

  1. Quickstart
  2. Authentication
  3. Sandbox
  4. Create a conversion
  5. Upload your own file
  6. Formats
  7. The conversion object
  8. Retrieve and list
  9. Deliver to your own storage
  10. Embed the player
  11. Player settings
  12. Control the player
  13. Playback protection
  14. Webhooks
  15. Safe retries
  16. Balance and billing
  17. Errors
  18. Limits and retention

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.

  1. Start a conversion. Send a link to your video, or the sandbox test link below. You get a conversion id back straight away.
1 · Start
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" }'
  1. Check the status, or wait for your webhook. When status is completed, the response holds the download links in outputs.
2 · Status
curl https://api.xclusivevr.com/v1/conversions/conv_8f2kq1Lx0aZb3YtQwE1r \
  -H "Authorization: Bearer xvr_test_..."
  1. 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:

Header
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 queued to processing and is completed after about 20 seconds. progress counts 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 gives 400 invalid_source. We never download it.
  • No video at hand? Use https://sandbox.xclusivevr.com/trailer.mp4, or any other path on that host without fail in it: it always completes. The host does not exist; the sandbox only recognises it.
  • POST /v1/uploads gives a sandbox upload_id (it starts with sandbox/). Nothing is uploaded and its upload_url accepts no files: create the conversion with the upload_id straight away. Any other upload_id ends as failed with upload_not_found, just like an upload that never arrived in live mode.
  • The links in outputs point to real sample files you can download: an MP4 for sbs and a MOV for spatial. They are not a conversion of your video. With "stream": true, player plays 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 URLerror.code
https://sandbox.xclusivevr.com/fail/source_unreachablesource_unreachable
https://sandbox.xclusivevr.com/fail/source_redirectsource_redirect
https://sandbox.xclusivevr.com/fail/source_too_largesource_too_large
https://sandbox.xclusivevr.com/fail/unreadable_videounreadable_video
https://sandbox.xclusivevr.com/fail/no_video_streamno_video_stream
https://sandbox.xclusivevr.com/fail/video_too_longvideo_too_long
https://sandbox.xclusivevr.com/fail/source_not_360source_not_360
https://sandbox.xclusivevr.com/fail/insufficient_balanceinsufficient_balance
https://sandbox.xclusivevr.com/fail/daily_limit_reacheddaily_limit_reached
https://sandbox.xclusivevr.com/fail/import_timeoutimport_timeout
https://sandbox.xclusivevr.com/fail/processing_errorprocessing_error
https://sandbox.xclusivevr.com/fail/conversion_failedconversion_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.

FieldTypeDescription
sourcestringAn https link to your video. Must be publicly reachable, without redirects. Give either source or upload_id.
upload_idstringThe id from POST /v1/uploads, if you uploaded the file yourself.
outputstring 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).
formatstring"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".
depthstringOptional. 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".
webhookstringOptional. 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.
streambooleanOptional. 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).
destinationboolean or objectOptional. 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.
titlestringOptional. Your own name for the video in your dashboard, up to 150 characters. Without it we use the file name.
metadataobjectOptional. 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".
Request
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" }
  }'
Response
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

1 · Get an upload URL
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 }'
Response
HTTP/1.1 201 Created

{
  "upload_id": "api/66f1.../1791633600000_trailer.mp4",
  "upload_url": "https://...",
  "method": "PUT",
  "expires_in": 3600
}
2 · Upload the file (within 1 hour)
curl -X PUT "<upload_url>" \
  -H "Content-Type: video/mp4" \
  --upload-file trailer.mp4

Upload 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.

3 · Convert it
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
FilesMP4 (recommended), MOV, M4V, MKV, WebM, AVI, WMV, ASF, MPG, MPEG, VOB, MTS, M2TS, 3GP and FLV
Video insideH.264, H.265 (HEVC), AV1, VP9, VP8, Apple ProRes, MPEG-2, Xvid/DivX and Windows Media Video
ResolutionUp to 8K
Frame rateAny, including slow motion at 120 or 240 fps
HDRHDR10, 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")
FileMP4MOV
Plays onMeta Quest, Pico, Samsung Galaxy XR and every 3D playerApple Vision Pro; in 2D on any other screen
VideoH.265 (HEVC), left and right eye side by side, each at full resolutionMV-HEVC: both eyes in one video
ResolutionYour own, up to 4K per eye. An 8K source comes back at 4K per eye.Your own, up to 4K per eye
Frame rateYour own. Above 60 fps comes back at 60 fps, the highest headsets play smoothly.Same as side-by-side
HDRStays HDR: 10-bit with your original coloursStays HDR in the file; the Photos app on Vision Pro still shows spatial video in SDR
SoundYour original soundYour 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

FieldDescription
idUnique id, starts with conv_.
mode"live" or "test".
statusqueued → processing → completed, or failed.
progress0 to 100. Never goes down; 100 only when completed. null when failed.
output, formatWhat you asked for.
duration_secondsThe length of your video, as we measured it. null until measured.
streamtrue when the video is hosted.
minutesThe 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.
errorWhen failed: { code, message }. See Errors.
titleThe name you gave, or null.
metadataYour own data, unchanged.
created_at, completed_atISO 8601 timestamps, UTC.
outputsWhen completed: a download link per output (sbs, spatial). Each link works for 7 days; fetch the conversion again for fresh links.
expires_atWhen the links in outputs stop working.
available_untilUntil 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.
playerWith hosting, when completed: { embed_url, iframe }. See Embed the player.
stream_urlWith 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_untilWith hosting: until when we host the video and the player plays it. Moves a year on with every renewal.
hostingWith hosting: { auto_renew, renewals }. Whether the hosting renews automatically after each year, and how often it has.
removedOnly 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.
destinationWith your own storage: { path }. Otherwise null.
deliveryWith 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.
A completed conversion
{
  "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.

List
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.

  1. 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.
  2. Send destination with a conversion, or tick Deliver every conversion here on your account page.
Convert and deliver to your bucket
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.

On the conversion
"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.completed waits until the files are in your bucket, and then includes delivery.
  • If writing fails, we try again after 5 and after 30 minutes. After that delivery.status is failed with the reason, and your download links in outputs keep working.
  • Fixed the cause, for example a new key? Deliver again with POST /v1/conversions/:id/deliver, optionally with a new path. You then get conversion.delivered or conversion.delivery_failed.
  • Without hosting, we delete our copy 30 days after delivery. With "stream": true we keep hosting it as usual.
  • In the sandbox nothing is written: delivery shows 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.

Embed
<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 allow attribute: without xr-spatial-tracking the 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_url instead.
  • 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/balance shows this month's total.
  • In the sandbox, player points 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.

Embed URL with settings
https://www.xclusivevr.com/embed/k7Qx2aPm?autoplay=1&loop=1&t=1m30s&lang=de
SettingDefaultWhat it does
autoplay=1offStarts 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=1offKeeps the sound off, also when the viewer enters VR. The viewer can still turn it on.
loop=1offPlays the video again when it ends.
t=900Starts at this point. Seconds (90), or 1m30s, or 01:30.
controls=0onHides 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=dethe viewer's languageLanguage of the buttons and messages: en, de, es, pt or fr.
player_id=herononeSent back with every message, so you can tell several players on one page apart.
env=beachyour account settingPlayer 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.

Example
<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.

Eventdata
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 · exitxrThe 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.

MethodvalueWhat it does
play—Starts playing. Without a tap from the viewer, browsers only allow this muted; otherwise you get an error event.
pause—Pauses.
seeksecondsJumps to this point.
setVolume0 to 1Sets the volume.
setMutedtrue or falseTurns 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_domains the 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_required the 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 low is 10 times a normal hour and at least 3,000 minutes, normal 5 times and at least 1,000 minutes, high 3 times and at least 300 minutes.
Change the settings · PATCH /v1/playback-settings
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.

Response
{
  "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.

Request · POST /v1/conversions/:id/playback-links
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 }'
Response
{
  "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 error event (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.

Create a signing key · POST /v1/signing-keys
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) and id on your server, for example as XVR_SIGNING_KEY and XVR_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-keys lists your keys (never the private key) with last_used_at. DELETE /v1/signing-keys/:id revokes one: links signed with it stop playing straight away.
Sign a link for one viewer
// 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 fieldValue
kid (header)The id of your signing key, xvrsk_…
alg (header)RS256
subThe id of the conversion, conv_…
audplayback
expUnix 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:

EventWhen
conversion.completedThe 3D files are ready. data.outputs holds the download links.
conversion.failedThe conversion failed. data.error says why. Minutes already charged have been refunded.
conversion.deliveredOnly after POST /v1/conversions/:id/deliver: the files are now in your own storage.
conversion.delivery_failedOnly after POST /v1/conversions/:id/deliver: delivering to your own storage failed again. data.delivery.error says why.
conversion.deletedYou deleted the conversion, with DELETE /v1/conversions/:id or on your account page. data is { id, object, deleted: true }.
test.pingOnly when you press "Send test event" on your account page. data.message holds a short text; use it to check your endpoint and signature.
What we send
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.

Node.js
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
});
Python
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 2xx within 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-Id to 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.

Idempotency-Key
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, 429 or a 5xx) 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

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 currency in GET /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. See prices_per_minute in GET /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 at prices_per_minute.hosting_renewal_per_year per 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 with POST /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_balance and nothing is charged. With no credit and no test minutes, a new request gets 402 straight 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_minutes in GET /v1/balance.
  • Top up in your dashboard (top_up_url is 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.

Error
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "invalid_source",
    "message": "source must be an https URL."
  }
}

When you make the request

HTTPCodeMeaning
400invalid_json · invalid_source · invalid_output · invalid_format · invalid_depth · invalid_stream · invalid_webhook · invalid_metadata · invalid_title · invalid_upload_id · invalid_idempotency_key · invalid_destinationPOST /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.
400invalid_field_nameA field name starts with $ or contains a dot, also inside metadata. Rename it; we do not remove it silently.
400invalid_size · file_too_largePOST /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).
400invalid_expires_in · not_hostedPOST …/playback-links: expires_in out of range, or the conversion has no hosting and so no player.
400invalid_namePOST /v1/signing-keys: name must be a string of at most 100 characters.
400invalid_auto_renew · not_hostedPOST …/hosting: auto_renew must be true or false, and only hosted conversions can renew.
400invalid_domain · too_many_domains · invalid_signed_links_required · invalid_spike_alerts · invalid_sensitivityPATCH /v1/playback-settings: a value is not valid; the message says which.
400storage_not_connectedYou sent destination, but no storage is connected on your account page.
401unauthorized · key_expiredNo key, an unknown key, a revoked key, or a key past its expiry date (create a new one on your account page).
402insufficient_balanceYour balance is empty.
403account_suspendedYour account is suspended. Contact us.
403live_key_requiredPATCH /v1/playback-settings, or creating or revoking a signing key, with a test key. These apply to your live videos.
404not_foundNo such route, or no conversion with this id in this mode.
409idempotency_key_reused · request_in_progressSee Safe retries.
409not_completed · delivery_in_progressPOST …/deliver: the conversion is not completed yet, or a delivery is running.
409not_readyPOST …/playback-links: the conversion is not finished yet, or its player is not available.
409too_many_signing_keysPOST /v1/signing-keys: you already have 5 active signing keys. Revoke one first.
413request_too_largeThe request body is too large.
415unsupported_media_typeSend the body as UTF-8 JSON with Content-Type: application/json.
429rate_limitedMore than 120 requests a minute with this key. Wait and retry.
429too_many_active_conversionsYour account already has the maximum number of conversions running (2 for self-service accounts). Retry when one is ready; see Retry-After.
429daily_limit_reachedYour daily limit of video minutes is used up. Retry after 00:00 UTC; see Retry-After.
500internal_errorSomething 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.

CodeMeaning
source_unreachableWe could not download the source URL.
source_redirectThe source URL redirects. Give the final URL.
source_too_largeThe file is larger than 150 GB.
upload_not_foundNo file was uploaded for this upload_id.
unreadable_videoWe could not read this video file.
no_video_streamThe file contains no video.
video_too_longLonger than 30 minutes (or the limit agreed for your account). Nothing was charged. For longer videos, contact customer support.
source_not_360format is "360" but the file is not twice as wide as it is tall (equirectangular).
insufficient_balanceNot enough minutes for this video. Nothing was charged.
daily_limit_reachedYour daily limit is reached.
import_timeoutFetching the source took too long.
conversion_failedThe conversion itself failed. Your minutes have been refunded.
invalid_sourceThe source URL is not allowed (for example an internal address) when we fetched it.
processing_errorSomething 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_failureTest only: the source URL contained "fail".

Limits and retention

WhatLimit
Requests120 a minute per key
Conversions at the same time2 for self-service accounts; more on request
Video minutes a day240 for self-service accounts (resets at 00:00 UTC); more on request
Video lengthUp to 30 minutes. Longer videos by agreement: contact customer support.
Frame rateAny; the 3D video is delivered at up to 60 frames per second
File sizeUp to 150 GB from a source URL or the dashboard; up to 5 GB through upload_url
ResolutionUp to 8K in; the 3D video comes back at up to 4K per eye
Download links7 days per link; fetch the conversion for fresh ones
Converted filesDownloadable 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 minutes25,000 a month included for every account, then €1.50 per 1,000 minutes (or the equivalent in your account currency)
Your source fileDeleted 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.

Email API supportHelp center
Logotipo de Xclusive VR

Convierte tus videos 2D en experiencias 3D inmersivas con tecnología de conversión basada en IA.

English · Deutsch · Español · Português · Français

Enlaces rápidos

ConvertirPara Meta QuestPara Apple Vision ProConversión a VR180Conversión de video 360Cómo funciona la conversión de video 2D a 3DPreciosAyudaPreguntas frecuentesContactoEjemplosPara empresasAPI de video 3DBlogQuiénes somosPrivacidad

Legal

Términos y condicionesPolítica de privacidadAcuerdo de tratamiento de datosTérminos de la APICookiesDenunciar contenido

Apple Vision Pro, Meta Quest, Pico, Samsung Galaxy XR y YouTube son marcas comerciales de sus respectivos propietarios. No estamos afiliados ni respaldados por Apple, Meta, Pico, Samsung ni Google.

© 2026 Xclusive VR. Todos los derechos reservados.

Xclusive VR es un nombre comercial de PVG Technologies B.V., Países Bajos · Cámara de Comercio 99645939