API

Everything the dashboard does, your code can do: upload, fetch, list, rename, delete, encode. Two credentials — an API key for your account, a zone key for your files.

Quick start

Create an account, take the two keys off the dashboard, then:

# upload
curl -X PUT --data-binary @lecture.mkv \
  -H "Authorization: Bearer $ZONE_KEY" \
  https://storage.vylo-tech.com/v1/your-zone/w1/lecture.mkv

# an MKV: no browser plays it. ask what to do about it
curl -H "Authorization: Bearer $ZONE_KEY" \
  "https://storage.vylo-tech.com/v1/zones/your-zone/probe?key=w1/lecture.mkv"

#   "plays_in_browser": false
#   "suggested_preset": "remux"

# fix it. encoding is free, and the original is left alone
curl -X POST -H "Authorization: Bearer $ZONE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"zone":"your-zone","key":"w1/lecture.mkv",
      "preset":"remux"}' \
  https://storage.vylo-tech.com/v1/encodes

# hand somebody a link that expires
curl -X POST -H "Authorization: Bearer $ZONE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"zone":"your-zone","key":"w1/lecture.mp4",
      "ttl":3600}' \
  https://storage.vylo-tech.com/v1/sign

Authentication

Every request carries Authorization: Bearer <key>. There are two, and which one you need is on each endpoint below.

vsk_…API key Your account: usage, balance, keys, payments. Never put it in a browser.
vz_…zone key Your files: upload, list, encode, delete. Server-side.
vzt_…upload token A short-lived stand-in for the zone key, minted by /account/upload-token. This is what a browser should hold: it expires, and it works on nothing but your own zone.

Keys are shown once and stored only as a hash. A lost one is replaced by rotating it, not by looking it up.

Files

Upload, fetch, rename and delete. A key may contain slashes, which is what makes folders: there are no folder records to create, and one exists exactly while something is in it.

PUT /v1/your-zone/{key} zone key or upload token
Upload a file, or replace one

Answers as soon as the bytes are safely on this server, not when they reach the storage box — that second hop is ours to worry about. Send Content-Type if you know it; it is what delivery will send back.

zone your zone name · path
key the path within it, e.g. w1/intro.mp4 · path
Content-Type the file’s type · header

Request

curl -X PUT --data-binary @lecture.mp4 \
  -H "Authorization: Bearer $ZONE_KEY" \
  -H "Content-Type: video/mp4" \
  https://storage.vylo-tech.com/v1/your-zone/w1/intro.mp4

Response

{
  "zone": "your-zone", "key": "w1/intro.mp4",
  "size": 734003200, "sha256": "9f86d08...",
  "content_type": "video/mp4", "state": "spooled",
  "url": "https://cdn.vylo-tech.com/your-zone/w1/intro.mp4"
}
GET /v1/your-zone/{key} signed link
Fetch a file

Supports range requests, ETag and 304, which is what lets a video player seek instead of downloading everything first. For a private zone the request needs a signature — see Links. Delivery is normally done straight from https://cdn.vylo-tech.com instead.

Request

# the first kilobyte only
curl -r 0-1023 \
  "https://cdn.vylo-tech.com/your-zone/w1/intro.mp4?exp=1789..&sig=SrEb.."
HEAD /v1/your-zone/{key} signed link
Size, type and ETag without the body
DELETE /v1/your-zone/{key} zone key or upload token
Delete one file

Also removes any directory it was the last thing in.

Request

curl -X DELETE -H "Authorization: Bearer $ZONE_KEY" \
  https://storage.vylo-tech.com/v1/your-zone/w1/intro.mp4

Response

{ "deleted": "your-zone/w1/intro.mp4" }
GET /v1/zones/your-zone/objects zone key or upload token
List files, or one folder at a time

With delimiter=/ this answers the way object stores have since S3: the keys directly under prefix, plus the distinct next segments as folders, each carrying the count and bytes of everything beneath it.

prefix the folder to look in, e.g. w1/ · query
delimiter set to / to group into folders · query
limit 1-1000, default 200 · query
offset for paging · query

Request

curl -H "Authorization: Bearer $ZONE_KEY" \
  "https://storage.vylo-tech.com/v1/zones/your-zone/objects\
?delimiter=/&prefix=w1/"

Response

{
  "zone": "your-zone",
  "prefix": "w1/",
  "total": 2,
  "folders": [
    { "prefix": "w1/720p/", "name": "720p",
      "objects": 480, "bytes": 91234567 }
  ],
  "objects": [
    { "key": "w1/intro.mp4", "name": "intro.mp4",
      "size": 734003200, "content_type": "video/mp4",
      "state": "stored", "sha256": "9f86d08..." }
  ]
}
POST /v1/zones/your-zone/move zone key or upload token
Rename a file, or move it between folders

A rename on the storage box, so moving a 2 GB lecture does not move 2 GB. It will not overwrite something already there.

source the current key · body
destination the new key · body

Request

curl -X POST -H "Authorization: Bearer $ZONE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source":"inbox/lecture.mp4","destination":"w1/intro.mp4"}' \
  https://storage.vylo-tech.com/v1/zones/your-zone/move

Response

{ "moved": "inbox/lecture.mp4", "to": "w1/intro.mp4" }
DELETE /v1/zones/your-zone/folder zone key or upload token
Delete a folder and everything under it

prefix is required and cannot be empty: deleting a folder and emptying an account must not be one keystroke apart.

prefix the folder, e.g. w1/ · query

Request

curl -X DELETE -H "Authorization: Bearer $ZONE_KEY" \
  "https://storage.vylo-tech.com/v1/zones/your-zone/folder?prefix=w1/"

Response

{ "deleted": 482, "prefix": "w1/" }

Resumable uploads

The tus 1.0 protocol — core, creation, creation-with-upload and termination. Use it for anything big enough that the connection itself is the risk. Existing tus clients (Uppy, tus-js-client, tus-py) work against this unchanged.

POST /v1/uploads zone key or upload token
Start a resumable upload

Upload-Metadata carries zone and key, each base64-encoded. The Location header is what you PATCH to.

Upload-Length total size in bytes · header
Upload-Metadata zone <b64>,key <b64>,filetype <b64> · header

Request

ZONE=$(printf your-zone | base64)
KEY=$(printf 'w1/intro.mp4' | base64)

curl -i -X POST \
  -H "Authorization: Bearer $ZONE_KEY" \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: 734003200" \
  -H "Upload-Metadata: zone $ZONE,key $KEY" \
  https://storage.vylo-tech.com/v1/uploads

Response

HTTP/1.1 201 Created
Location: /v1/uploads/Wmx1EeGoh3Uz06iO
Upload-Offset: 0
HEAD /v1/uploads/{id} zone key or upload token
Ask where an upload got to

The one question resuming depends on. Answers Upload-Offset and Upload-Length.

PATCH /v1/uploads/{id} zone key or upload token
Send the next stretch of bytes

A wrong offset answers 409 carrying the right one, so a confused client can always recover. When the last byte lands, the response carries X-Vylo-Object with the delivery URL.

Upload-Offset where this chunk starts · header
Content-Type application/offset+octet-stream · header

Request

curl -X PATCH -H "Authorization: Bearer $ZONE_KEY" \
  -H "Tus-Resumable: 1.0.0" -H "Upload-Offset: 8388608" \
  -H "Content-Type: application/offset+octet-stream" \
  --data-binary @chunk2.bin \
  https://storage.vylo-tech.com/v1/uploads/Wmx1EeGoh3Uz06iO
DELETE /v1/uploads/{id} zone key or upload token
Give up on an upload

Encoding

Turn what somebody uploaded into something a browser will play. Encoding itself is free — you are billed only for the storage the result occupies, at the same rate as anything else. Jobs run one at a time and the original is never replaced.

GET /v1/zones/your-zone/probe zone key or upload token
What is inside a file, and whether a browser will play it

Worth asking before queueing anything. suggested_preset is null when the file is already fine, "remux" when the codecs are good but the container is not, and a size preset when it genuinely needs re-encoding.

key the object to inspect · query

Request

curl -H "Authorization: Bearer $ZONE_KEY" \
  "https://storage.vylo-tech.com/v1/zones/your-zone/probe?key=w1/lecture.mkv"

Response

{
  "zone": "your-zone", "key": "w1/lecture.mkv",
  "duration": 3612.5, "container": "matroska,webm",
  "video_codec": "h264", "width": 1920, "height": 1080,
  "audio_codec": "aac",
  "plays_in_browser": false,
  "suggested_preset": "remux"
}
GET /v1/encodes/presets no auth
What can be produced

remux repackages without touching a pixel and runs at disk speed — it is the fix for an MKV or MOV that already holds H.264. The size presets re-encode and take real time.

Request

curl https://storage.vylo-tech.com/v1/encodes/presets

Response

{
  "available": true,
  "presets": [
    { "name": "remux",     "produces": ".mp4",
      "kind": "video", "reencodes": false },
    { "name": "mp4-360p",  "produces": ".360p.mp4",  "kind": "video" },
    { "name": "mp4-480p",  "produces": ".480p.mp4",  "kind": "video" },
    { "name": "mp4-720p",  "produces": ".720p.mp4",  "kind": "video" },
    { "name": "mp4-1080p", "produces": ".1080p.mp4", "kind": "video" },
    { "name": "audio-mp3", "produces": ".mp3",       "kind": "audio" },
    { "name": "thumbnail", "produces": ".jpg",       "kind": "image" }
  ]
}
POST /v1/encodes zone key or upload token
Queue an encode

Answers 202 straight away: encoding a lecture takes minutes and holding a request open for it is how integrations time out. The result lands beside the source — w1/lecture.mkv becomes w1/lecture.720p.mp4 — and the original is left alone.

zone your zone · body
key the source object · body
preset a name from /v1/encodes/presets · body

Request

curl -X POST -H "Authorization: Bearer $ZONE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"zone":"your-zone","key":"w1/lecture.mkv","preset":"mp4-720p"}' \
  https://storage.vylo-tech.com/v1/encodes

Response

{
  "id": "pUFGDr351gfMIzYJ", "zone": "your-zone",
  "source": "w1/lecture.mkv", "key": "w1/lecture.720p.mp4",
  "preset": "mp4-720p", "state": "queued", "progress": 0
}
GET /v1/encodes/{id} zone key or upload token
How an encode is going

state is queued, running, done or failed. progress is 0 to 1 while running.

Request

curl -H "Authorization: Bearer $ZONE_KEY" \
  https://storage.vylo-tech.com/v1/encodes/pUFGDr351gfMIzYJ

Response

{ "id": "pUFGDr351gfMIzYJ", "state": "running", "progress": 0.42,
  "key": "w1/lecture.720p.mp4", "error": null }
GET /v1/zones/your-zone/encodes zone key or upload token
Every encode for a zone, newest first
DELETE /v1/encodes/{id} zone key or upload token
Cancel an encode

A running job has its encoder stopped; a queued one is simply dropped.

Embedding the player

Put a video on your own site with an iframe, or in an app with a WebView. The player reads the renditions, poster and caption tracks for itself, so a quality menu appears as soon as you have encoded more than one size.

GET /play/your-zone/{key} signed link
The player page — this is what goes in the iframe

Takes the exp and sig from a signed link. Public by design, because the signature IS the permission, and the one page that allows being framed on another site.

autoplay true to start on load (implies muted; browsers require it) · query
muted true to start silent · query
loop true to repeat · query
controls false to hide them entirely · query
t start here: 90, 1m30s or 01:30 · query
color accent colour, e.g. 4c8dff · query
preload none, metadata (default) or auto · query
poster override the poster image · query
ar 16:9, 9:16, 4:3 or a number. Default auto — the video's own · query

Request

<!-- padding-top reserves the shape before anything loads,
     so the page does not jump. 56.25% is 16:9; use
     (height / width) for anything else. -->
<div style="position:relative;padding-top:56.25%">
  <iframe
    src="https://storage.vylo-tech.com/play/your-zone/w1/intro.mp4
         ?exp=1789999999&sig=SrEb..&autoplay=true&muted=true"
    style="position:absolute;inset:0;width:100%;height:100%;border:0"
    allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
    allowfullscreen loading="lazy"></iframe>
</div>

<!-- or let the client work it out from the file -->
const { html } = await vylo.embedFor('w1/intro.mp4');
GET /v1/zones/your-zone/playable signed link
Everything a player needs, already signed

The renditions to offer as qualities, the poster and any caption tracks. Authenticated EITHER by a zone key or by the signature the viewer already holds — which is how the player page, holding no secret, builds a quality menu. Signatures it returns never outlive the one that asked for them.

key the video · query
exp from your signed link · query
sig from your signed link · query
ttl seconds; capped at what your link has left · query

Response

{
  "key": "w1/intro.mp4",
  "poster": "https://cdn.vylo-tech.com/your-zone/w1/intro.jpg?exp=..&sig=..",
  "sources": [
    { "src": "..intro.mp4?..",      "size": 1080, "label": "Original" },
    { "src": "..intro.720p.mp4?..", "size": 720,  "label": "720p" },
    { "src": "..intro.360p.mp4?..", "size": 360,  "label": "360p" }
  ],
  "tracks": [
    { "kind": "captions", "srclang": "en", "src": "..intro.vtt?.." }
  ],
  "duration": 612.5
}

The client library

One file, no build step. It uses fetch and nothing else, so the same code runs in a browser, on your server under Node 18+, and in React Native.

<script src="https://storage.vylo-tech.com/sdk/vylo.js"></script>
<script>
  // In a browser, never ship a zone key. Ask your own backend for a
  // short-lived token instead; the client refreshes it by itself.
  const vylo = new Vylo({
    zone: 'your-zone',
    getToken: () => fetch('/api/vylo-token').then(r => r.json()),
  });

  const input = document.querySelector('input[type=file]');
  input.onchange = async () => {
    const result = await vylo.uploadVideo(
      'w1/lecture.mp4', input.files[0],
      { onProgress: (p) => console.log(Math.round(p * 100) + '%') },
    );
    // uploadVideo probes it and, if no browser would play it, encodes it.
    console.log('playable at', result.playableKey);
  };
</script>

On your server

import { Vylo } from 'https://storage.vylo-tech.com/sdk/vylo.mjs';

const vylo = new Vylo({
  zone: 'your-zone',
  zoneKey: process.env.VYLO_ZONE_KEY,
});

// a link for one student, that does not work if they forward it
const link = await vylo.sign('w1/lecture.mp4', {
  ttl: 3600, viewer: 'student-4182', ip: req.ip,
});

// the iframe to drop into a page
const html = vylo.embedCode(link, { autoplay: false, color: '4c8dff' });

React Native — WebView

import { WebView } from 'react-native-webview';

// mint the link on your server, never in the app
const ORIGIN = 'https://storage.vylo-tech.com';
const uri = ORIGIN + '/play/your-zone/w1/lecture.mp4?' + query;

const ref = useRef(null);

<WebView
  ref={ref}
  source={{ uri }}
  allowsFullscreenVideo
  allowsInlineMediaPlayback          // iOS: or it goes fullscreen-native
  mediaPlaybackRequiresUserAction={false}
  onMessage={(e) => {
    const m = JSON.parse(e.nativeEvent.data);
    if (m.vylo === 'timeupdate') save(m.currentTime);
    if (m.vylo === 'ended') markLectureWatched();
  }}
/>

// to control it, inject rather than postMessage — it behaves the
// same on both platforms
ref.current.injectJavaScript(
  "window.postMessage(JSON.stringify({vylo:'seek',value:90}));true;"
);

React Native — native player

import Video from 'react-native-video';

// /playable returns every rendition already signed
const { sources, poster, width, height } = await res.json();

<Video
  source={{ uri: sources[0].src }}
  poster={poster}
  controls
  resizeMode="contain"
  style={{ aspectRatio: width / height }}
  onEnd={markLectureWatched}
/>

The client library in an app

// The library uses fetch and nothing else, so it runs on Hermes
// unchanged — no URL, URLSearchParams, TextEncoder or btoa.
import { Vylo } from 'https://storage.vylo-tech.com/sdk/vylo.mjs';

const vylo = new Vylo({
  zone: 'your-zone',
  getToken: () => fetch('https://your-api/vylo-token').then(r => r.json()),
});

await vylo.list('w1/');
await vylo.sign('w1/lecture.mp4', { ttl: 3600 });

// Uploading: React Native has no File object, so turn the file URI
// into a Blob first. RN Blobs support slice, so resumable works.
const blob = await fetch(localFileUri).then(r => r.blob());
await vylo.upload('w1/lecture.mp4', blob, {
  contentType: 'video/mp4',
  onProgress: (p) => setProgress(p),
});
One thing not to do in an app: bind a link to an IP. A phone moves between wifi and mobile data mid-lecture and the address changes with it, so the link stops working halfway through. Use a short ttl and a viewer id instead.

Controlling an embedded player

const player = new VyloPlayer('#lecture');   // the iframe
player.on('timeupdate', ({ currentTime, duration }) => save(currentTime));
player.on('ended', () => next());
player.seek(90);
player.play();

Keeping a video private

What a private zone actually guarantees, and what it does not. Worth reading once before you hand a link to anybody.

private by default A new zone serves nothing without a signature. There is no unlisted-but-reachable state: an unsigned request is refused whether it is a GET, a HEAD or a byte range.
one object A signature covers that file, that zone and that expiry together. It does not work on the file beside it, the same path in another zone, or a second past its time — and moving the expiry breaks it.
viewer Adds an id of yours to the link, covered by the signature. It does not stop the link being forwarded; it makes a forwarded one say whose it was, which is usually what ends the habit.
ip Stops it being forwarded. The same URL from another network does not play. The cost is real: a phone moving from wifi to mobile data changes address and the link stops there too, so use it where that is acceptable.
short ttl The simplest control and the one people skip. Mint a link when somebody presses play, not when the page loads.
not DRM Anyone who can watch a video can record it. This makes a link useless to somebody it was not issued to; it cannot make a file unwatchable by a person it was.

Your account

Authenticated with your API key (vsk_...) rather than your zone key. These live at the root rather than under /v1, because they are this site rather than the storage service.

GET /me API key
Usage, quota and balance

Request

curl -H "Authorization: Bearer $API_KEY" https://storage.vylo-tech.com/me

Response

{
  "client": {
    "zone": "your-zone", "keyPrefix": "vsk_3bec6588"
  },
  "usage": {
    "bytes": 19276175, "objects": 11, "pending": 0,
    "quotaBytes": 5368709120, "quotaUsedPct": 0.4
  },
  "balance": {
    "balanceUsd": "0.0000", "thisMonthUsd": "1.0000",
    "pricePerTbMonth": "40.00"
  }
}
POST /account/upload-token API key
A short-lived credential scoped to your zone

What a browser should hold instead of your zone key: it expires, and it works on nothing but your own zone. Anywhere these docs say $ZONE_KEY, one of these works too.

ttl seconds, 60 to 86400; default 900 · body

Request

curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -d '{"ttl":900}' \
  https://storage.vylo-tech.com/account/upload-token

Response

{
  "zone": "your-zone",
  "token": "vzt_your-zone.1789999999.SrEb...",
  "expires": 1789999999
}
POST /account/zone-key/rotate API key
Replace your zone key

Shown once. Anything still using the old one stops immediately.

POST /account/key/rotate API key
Replace your API key
GET /account/charges API key
What you have been billed, by month
POST /account/payment-claims API key
Tell us you have paid

A claim never credits anything by itself; it is applied once it is confirmed.

amount USD · body
method bank_transfer, fib, fastpay, cash, other · body
reference a transfer id or receipt number · body

Errors

Failures answer with a JSON body carrying detail, in words written for a person rather than a status page.

400A name or a parameter this service will not accept, and why.
401The key is not valid for that zone.
403A signed link that is wrong, or has expired.
404No such object, zone or job.
409A move onto something that exists, or a tus offset that disagrees with ours — which carries the right one back.
413Over the single-file limit, or over your quota. The message has the figures.
507The delivery server is low on disk and is refusing uploads. Nothing is broken; try later.