API Reference/
Developer Docs

ZiB Network

Objects are now stored as independently-encrypted 5 MiB chunks under one root hash per file, and the file is replicated to ≥3 nodes — no erasure coding. (A holder holds every chunk of a file; chunks are not split across nodes.) If you pass the whole registration to uploadDirect, this is transparent. Worth a quick read before you re-verify your integration:

  • →2026-10-29: generated URLs now use cdn.mosnet.io, so add it to your CSP (the old hosts keep working). Hostnames & CSP → The /s3/ path is deprecated. Server-side → Full list →
  • →Every upload is chunked. initiate always returns upload_type: "multipart" — drop any single-vs-multipart branching. Upload flow →
  • →New fetchObject(fileId) — read an object directly from a node and decrypt in the browser. Optional; CDN URLs are unchanged. SDK reference →
  • →Uploads run in the browser (the SDK is browser-only), and usage/billing reads are dashboard-only — an access-key call to /billing/* or /me/* returns 401. Server-side →
  • →Every stored byte is now re-verified on a rolling schedule against its content hash; a copy that fails is quarantined and replaced from a good copy automatically. And video durability is per quality variant — each variant is one content-addressed unit on ≥3 nodes, held completely or not at all. No API or SDK change. Encoding pipeline →

Overview

ZiB is decentralised encrypted storage. Files are encrypted client-side before any bytes leave the browser, split into encrypted chunks and replicated across the storage network, and streamed back on read. Storage nodes never see plaintext — not the file content, not the filename, not the encryption key.

Your tenant credentials (access_key:secret_key) authenticate API calls. ZiB only knows about tenants — you manage your own users and decide who can access what. ZiB never stores user identities.

Encryption
Client-side
Storage
Distributed
Availability
High — node-failure tolerant
Auth
Bearer token

How It Works

Every file uploaded to ZiB goes through a four-stage pipeline before it is available on the network.

01Encrypt

ZiB generates a fresh per-file encryption key on the backend and returns it to the browser SDK in the upload registration object. The SDK encrypts the file in the browser before any bytes leave the device. The key is stored on the ZiB backend and never sent to storage nodes.

02Distribute

Every file is split into 5 MiB chunks and each chunk is encrypted independently, then stored as its own content-addressed blob under one root hash for the file. That whole set is what gets replicated: a node holds every chunk of a file or it is not a holder of that file, and the target is three independent holders. Chunks are not scattered across different nodes — each copy is complete, which is why losing any one holder costs nothing. Addressing chunks individually is what lets a reader fetch them from several holders at once, and lets the CDN serve a byte range without pulling the whole file.

03Isolate

Storage nodes receive encrypted chunks only — they never receive the encryption key or the plaintext. All node-facing identifiers are opaque UUIDs, so node operators cannot correlate stored chunks with real filenames or object keys.

04Serve

When a client requests a file via cdn.mosnet.io/objects/<uuid>?exp=…&tok=…, the CDN fetches the chunks from their holders, decrypts each one with the stored key, and streams the result. There is no erasure decode — nodes serve the stored chunks as they are. (cdn.zibnetwork.com serves the same paths — see Hostnames & CSP.) The exp/tok pair is a signed, expiring token. Whether a request without one is refused is a server-side switch, so do not build on unsigned URLs: store the file_id and resolve a URL when you render (Object URLs). A URL you built by hand can stop working; a resolved one will not.

Quick Start

Integrate ZiB storage into your app in five steps. The key architectural rule: credentials stay on your server, the SDK runs in the browser.

1

Get credentials

Sign in at app.zibnetwork.com → Dashboard → copy your Access Key and Secret Key. Store these as server-side environment variables only — never in browser code or client-side config.

Never ship your secret key to a browser. These keys have full access to your account: anyone who can read your page source, bundle or network traffic can upload, delete and read as you. Put them in .env on your server and use them only from server code (process.env.ZIB_ACCESS_KEY etc.).Your server holds the access key and secret, and mints short-lived, single-purpose values for the browser: an upload registration (/v1/api/upload/initiate), a signed object URL (/v1/api/object-url), or a publisher playback session token. The browser gets only those. Every SDK call that takes accessKey/secretKey (new ZiBStorage(…), waitForPinUpload) is server-side only.
2

Load the SDK

Add the SDK script to your HTML. No npm package or build step required.

html
<script src="https://app.zibnetwork.com/sdk/zib-sdk.js"></script>

Pin a version for production

The URL above always serves the latest build, so it can change under you when we deploy. Every release is also published at a version-pinned URL that never changes:

html
<script src="https://app.zibnetwork.com/sdk/zib-sdk-1.9.6.js"></script>

Pin it in production and upgrade deliberately. Check which build you are running with ZiBStorage.VERSION — worth including in any bug report, since it tells us exactly what you had loaded.

3

Server route — initiate upload

Create a server route that authenticates your user, then calls ZiB to generate the encryption key and upload URL. The registration object returned has no credentials — it is safe to pass to the browser.

javascript
// Node.js / Next.js App Router
export async function POST(req) {
  // 1. Authenticate with your own system first
  const user = await getAuthenticatedUser(req);
  if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const { fileName, fileSize, contentType } = await req.json();

  // 2. Build a scoped object key using your user's ID
  const key = `${user.id}/${Date.now()}-${fileName}`;

  // 3. Call ZiB — credentials stay on the server
  const res = await fetch('https://api.mosnet.io/v1/api/upload/initiate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}`,
    },
    body: JSON.stringify({
      bucket: 'my-bucket',
      key,
      file_size: fileSize,
      // Forward the browser's File.type so the CDN can return the
      // correct Content-Type header. Without this, iOS Safari refuses
      // to render <img> tags and OG/social link previews fall back to
      // text-only because the CDN defaults to application/octet-stream.
      content_type: contentType,
    }),
  });

  if (!res.ok) return Response.json({ error: 'Failed to initiate upload' }, { status: 502 });

  const registration = await res.json();

  // 4. Return the registration object — no credentials in here
  return Response.json(registration);
}
4

Browser — upload the file

In the browser, call your server route to get the registration, then pass it to ZiBStorage.uploadDirect. The SDK handles encryption and upload automatically.

javascript
// Browser — no credentials here
const registration = await fetch('/api/upload-initiate', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    fileName: file.name,
    fileSize: file.size,
    // Pass the browser's MIME type up so the CDN can serve the
    // correct Content-Type — required for <img> tags on iOS Safari
    // and for Open-Graph image unfurling on Slack / Twitter / iMessage.
    contentType: file.type || 'application/octet-stream',
  }),
}).then((r) => r.json());

const result = await ZiBStorage.uploadDirect(file, registration, {
  onProgress: ({ stage, progress }) => {
    console.log(`${stage}: ${progress}%`);
    // stage: 'encrypting' | 'uploading' | 'completing'
  },
});

console.log(result.file_id);   // ← persist this — nothing else

// SDK 1.9.4+: result.signed_url is the backend-signed object URL, for IMMEDIATE display
// (e.g. a preview right after upload). It expires at result.signed_url_expires_at
// (unix seconds, ~12 h) and then 403s — even while unsigned reads are still served —
// so never store it. Both are null if the backend sent no signed URL.
//
// result.cdn_url is unchanged: built by the SDK, NOT signed
//   https://cdn.zibnetwork.com/objects/<uuid>   (bare — no exp/tok)
// Do not store or render it. At render time, resolve a fresh signed URL from the
// file_id — see Object URLs.
5

Store the file_id

The upload result contains file_id and cdn_url. Persist the file_id — it is stable forever and it is what every other endpoint takes. Do not persist the object URL and render it later: a signed URL expires within 12 hours, and a bare one is not guaranteed to be served — either can start returning 403. Resolve one when you render — see Object URLs.

javascript
// Send back to your server to persist
await fetch('/api/save-file', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    file_id: result.file_id,       // stable forever — this is the thing to store
    object_key: key,               // the key you provided at initiate
  }),
}).then((r) => r.json());

// At render time, resolve a URL from the file_id (server-side, Bearer creds):
//   GET /v1/api/object-url?file_id=<file_id>          -> { url, expires_in_seconds }
//   POST /v1/api/object-urls  {"file_ids":[...]}      -> up to 100 at once
// HLS playback is different: /stream/<file_id>/master.m3u8 IS safe to store.
Earlier versions of this page told you to store cdn_url. That guidance was wrong and is the reason enforcement was rolled back on 2026-08-19. If you built against it, the fix is to keep the file_id you already have and resolve at render — no re-upload is needed.

The Upload Flow

Here is what happens under the hood when you call uploadDirect. The key principle: credentials and keys never cross the server/browser boundary.

Upload Flow
Your Server
ZiB API
Browser
ZiB Node
POST /upload/initiate
with master credentials
→→→
API generates:
• per-file encryption key
• file record
• one-time upload URL
Receives registration
forwards to browser
←←←
Receives registration
no credentials — safe in browser
uploadDirect(file, reg)
Encrypt locally
using key from registration
→→
Stores ciphertext
never sees plaintext or key
Returns:
{ file_id, cdn_url }
Master credentials never leave the server
Your Access Key and Secret Key are only ever used in your server route when calling /v1/api/upload/initiate. They are never included in the registration object or sent to the browser.
The registration is a capability for one object
The registration object contains the upload URL, the target node address, and the per-file encryption key itself — in plaintext, because the browser is what does the encrypting. Two things follow. It is NOT presigned — the URL is an unguessable UUID path bound to the one object row initiate created in advance, and the storage node accepts a PUT to it without checking a signature. It DOES expire: seven days after initiate, both /upload/{upload_id}/part/{n} and /upload/{upload_id}/complete return 410 Gone, and you start again with a fresh initiate. And it IS a secret — it carries that file's key — so treat it like one: hand it to the browser that is doing the upload, over TLS, and do not log it. What it cannot do is anything beyond that one object: no listing, no deleting, no creating a second object, and the backend refuses a second, different set of bytes for the same id.
Encryption happens before the node sees any bytes
The SDK encrypts the file in the browser before sending data to the node. The node receives only ciphertext.
Nodes store ciphertext only
Storage nodes never receive the encryption key. They store encrypted chunks indexed by UUID — they cannot read, correlate, or expose file content.
Self-enforcing encryption
If someone obtained a registration token and uploaded plaintext directly (bypassing the SDK), the CDN would decrypt those bytes with the stored key and serve garbage. There is no "unencrypted mode" — files uploaded without the SDK cannot be served correctly. The system is self-enforcing.
Every upload is chunked
There is no size threshold. Every file — a 4 KB thumbnail or a 40 GB master — is split into 5 MiB chunks, each encrypted separately, and initiate always returns upload_type: "multipart". The browser call is identical either way; uploadDirect handles the chunking transparently.
Re-upload to the same key replaces the prior file
If you upload to a (bucket, object_key) that already exists — for any file type, image or video or JSON — the prior file_id is marked cancelled and a NEW file_id is issued. Nothing is replaced in place. This is the single most common source of confusion, so be precise about which identifier means what: file_id identifies ONE upload's bytes and changes every time you replace the file; (bucket, object_key) identifies the SLOT and is stable forever. Route webhooks and lifecycle events by file_id, because they are about a specific upload. But if your schema has a slot that gets replaced — a poster for a title, an avatar for a user — key that slot on your own id or the object_key, and store the current file_id as a value you UPDATE on each upload rather than as the thing your rows are identified by. A schema that treats file_id as the stable reference will rewrite that column on every replacement and break anything pointing at the old one. See the "Re-uploads supersede previous uploads" callout in Webhooks for full event semantics.

The registration object

You never have to build any of this yourself — pass the whole object through to uploadDirect() untouched. It's documented here so the fields are recognisable when you're reading a log or debugging a failed upload.

json
{
  "upload_type":       "multipart",              // always "multipart" — every upload is chunked
  "upload_id":         "d3f1…",                  // the chunked-upload session
  "file_id":           "d3f1…",                  // permanent ID; also the CDN path segment
  "encryption_key":    "<64 hex chars>",         // 32-byte AES-256-GCM key, per file
  "encryption_iv":     "<24 hex chars>",         // 12-byte base IV
  "encryption_scheme": "per-chunk-gcm-v1",       // each 5 MiB chunk sealed separately
  "webtransport": {                              // optional — present only when the assigned
    "host":        "192.168.1.42",               //   node advertises a reachable WT endpoint.
    "port":        4433,                         //   Absent means "use the CF path"; it is a
    "cert_sha256": "<hex>"                       //   hint, never a requirement.
  }
}
The registration carries the encryption key. That is by design — the browser needs it to encrypt, and it authorises exactly one upload. But it means the registration should be minted per-upload for an authenticated user and never logged, cached, or handed to a third party.

SDK Reference

The ZiB SDK is a single browser-side JavaScript file with no dependencies. Load it once via script tag.

html
<script src="https://app.zibnetwork.com/sdk/zib-sdk.js"></script>

Primary upload method

staticZiBStorage.uploadDirect(file, registration, options)

The primary upload method. No class instantiation needed — call it as a static method. Splits the file into 5 MiB chunks, encrypts each one client-side using the key generated by the API during initiate, then uploads them to the assigned storage node. Chunking applies to every file regardless of size.

fileFile | Blob | ArrayBufferThe file to upload.
registrationobjectThe object returned by POST /v1/api/upload/initiate on your server.
options.onProgress({ stage, progress }) => voidProgress callback. stage is one of encrypting, uploading, completing. progress is 0–100.
ReturnsPromise<{ file_id, cdn_url, signed_url, signed_url_expires_at, … }> — persist file_id only. signed_url (1.9.4+) is the backend-signed object URL for immediate display; it expires at signed_url_expires_at (unix seconds, ~12 h) and then 403s, so never store it (both null if the backend sent none). cdn_url is a bare, unsigned URL the SDK builds from options.cdnEndpoint (default https://cdn.zibnetwork.com) — do not store or render it. waitForPinUpload and the S3-path instance methods return no signed_url.

Reading objects direct from a node

For most reads, use a signed object URL resolved on your server from the file_id (GET /v1/api/object-url, see Object URLs). It works in an <img> or <video> tag with no SDK at all, for up to 12 hours. Do not hand-build an unsigned /objects/<uuid> URL for markup. Reach for fetchObject when you specifically want the bytes to come from a storage node rather than through ZiB's infrastructure.

staticZiBStorage.fetchObject(fileIdOrUrl, options)

Fetches the object's encrypted 5 MiB chunks in parallel from multiple holders (fastest-first, per-frame failover), decrypts each per-chunk-gcm-v1 frame in the browser, and reassembles a Blob. Falls back to a single holder or the CDN path when the swarm can't apply. For progressive/streamed consumption, use fetchObjectStream.

fileIdOrUrlstringThe file_id UUID, or (v1.8.0+) a SIGNED object URL from GET /v1/api/object-url — the signature is carried through to the coordinates request so the read counts as signed. A bare file_id still works.
options.expnumber | stringSigned-URL expiry, if you stored the parts rather than the whole URL. Send with tok or not at all — a half-signed request is rejected.
options.tokstringSigned-URL token. Send with exp or not at all.
options.onProgress(bytesDecrypted: number) => voidCalled as bytes are decrypted.
options.apiEndpointstringOverride the API base. Defaults to https://api.zibnetwork.com.
options.cdnEndpointstringOverride the CDN base used for the fallback read. Defaults to https://cdn.zibnetwork.com (cdn.mosnet.io serves the same paths).
ReturnsPromise<Blob> — use URL.createObjectURL for media, or .arrayBuffer() / .text()
staticZiBStorage.fetchObjectStream(fileIdOrUrl, options)

Fetch an object as a progressive ReadableStream — 5 MiB frames are pulled across holders in a bounded read-ahead window (fastest-first) and emitted in order, so consumers start on the first frame instead of waiting for the whole object. Use for streamed downloads or feeding a MediaSource/SourceBuffer for playback. Falls back to a one-shot blob.stream() on a single holder / older nodes.

fileIdOrUrlstringThe file_id UUID, or (v1.8.0+) a SIGNED object URL from GET /v1/api/object-url — the signature is carried through to the coordinates request so the read counts as signed. A bare file_id still works.
options.expnumber | stringSigned-URL expiry, if you stored the parts rather than the whole URL. Send with tok or not at all — a half-signed request is rejected.
options.tokstringSigned-URL token. Send with exp or not at all.
options.windownumberRead-ahead depth — how many 5 MiB frames to fetch in parallel ahead of the consumer. Default 6.
options.onProgress(bytesDecrypted: number) => voidCalled as bytes are decrypted.
ReturnsPromise<ReadableStream<Uint8Array>>

Account-level operations

For listing, deleting, and encoding, instantiate the class with credentials. The constructor needs your secret key, so it belongs in server-side JavaScript only — never on a page a browser loads. (The SDK uses browser APIs for its upload and playback paths; the methods below are plain fetch calls, and the same requests work from any HTTP client.)

The S3-path methods are deprecated. listBuckets, listObjects, deleteObject, upload, uploadMultipart and uploadAuto call the S3-compatible /s3/ path, which is closing for private data — see The /s3/ path is deprecated. Do not build new code on them. For deletes use DELETE /v1/api/file/{file_id} (see Deleting Files); for uploads use uploadDirect. A non-S3 “list my files” endpoint is coming; until it ships, keep your own record of the file_ids you upload.
new ZiBStorage({ accessKey, secretKey })

SERVER-SIDE ONLY. Create an instance for account-level operations. Requires your tenant access key and secret key, so it must never be constructed in a page a browser loads — the secret would be readable by anyone viewing the page.

accessKeystringYour ZiB tenant access key.
secretKeystringYour ZiB tenant secret key.
storage.listBuckets()

Deprecated (S3 path). List all S3 buckets in your account.

ReturnsPromise<string> — S3-compatible XML
storage.listObjects(bucket)

Deprecated (S3 path). List all objects in a bucket. There is no non-S3 replacement yet — one is coming.

bucketstringBucket name. Names are unique per tenant, not globally — this resolves the bucket YOUR credentials own. A name you do not own is 404 NoSuchBucket, never another tenant’s bucket of the same name.
ReturnsPromise<string> — S3-compatible XML
storage.uploadAuto(bucket, key, file, options)

Deprecated (S3 path). Credentialed upload helper for accounts managing their own buckets — an alternative to the registration flow, not a replacement for it. Picks single-PUT or multipart using its own client-side threshold. Because it needs your secret key, it must not run in a browser; use uploadDirect.

bucketstringBucket name. Names are unique per tenant, not globally — this resolves the bucket YOUR credentials own. A name you do not own is 404 NoSuchBucket, never another tenant’s bucket of the same name.
keystringObject key.
fileFile | BlobThe file to upload.
options.thresholdnumberBytes above which multipart is used. Defaults to 50 MB. This is a client-side choice and is unrelated to how ZiB chunks the file for storage — that is always 5 MiB chunks.
options.onProgress({ stage, progress }) => voidProgress callback.
options.resumeKeystringOpaque key enabling resume of an interrupted multipart upload.
ReturnsPromise<{ file_id, cdn_url, ... }>
storage.deleteObject(bucket, key)

Deprecated (S3 path: DELETE /s3/{bucket}/{key}). Use DELETE /v1/api/file/{file_id}, or DELETE /v1/api/storage/{bucket}/{key} if you only have the key — same Bearer credentials, no /s3/ dependency. See Deleting Files.

bucketstringBucket name. Names are unique per tenant, not globally — this resolves the bucket YOUR credentials own. A name you do not own is 404 NoSuchBucket, never another tenant’s bucket of the same name.
keystringObject key.
ReturnsPromise<void>

Video encoding

storage.startEncoding(fileId)

Trigger HLS encoding (AES-128 encrypted) for an uploaded video. Calls POST /v1/api/encoding/jobs with your credentials; the credentials must belong to the account that owns the file (another account's file_id is 404). An in-flight job for the same file is returned instead of a second one, but a file that has already finished encoding IS encoded again — for the idempotent call (already_encoded / already_queued, force, webhooks, thumbnails, aspect variants) use POST /v1/api/encoding/request from your server. Jobs created this way send no encoding.* webhooks (no webhook URL is stored on them). Encoding runs asynchronously — poll getEncodingStatus or GET /v1/api/pipeline/{file_id}.

fileIdstringThe file_id UUID returned by uploadDirect.
opts.transcription'fastest' | 'recommended' | 'accurate'Optional. Run transcription on the same node after encoding.
opts.vision'standard' | 'hq'Optional. Run vision analysis on the same node after encoding.
ReturnsPromise<{ encoding_id, status, progress, hls_manifest_url }> — the response also carries dash_manifest_url, which is null (encoding produces HLS only)
storage.getEncodingStatus(jobId)

Poll the status of an encoding job (GET /v1/api/encoding/jobs/{id}). Answers only for jobs on files your account owns; any other id is 404.

jobIdstringThe encoding_id returned by startEncoding.
ReturnsPromise<{ encoding_id, status, progress, hls_manifest_url }>
storage.getCdnUrl(fileId)

Builds the BARE object URL locally — no token, no network call. A bare URL is only served while unsigned reads are allowed, which is a server-side switch (globally, or per account on request), so anything you persist or render from it (or from the cdn_url field of uploadDirect / uploadAuto, which is built the same way) can start returning 403. Do not use it for markup or storage. Store the file_id and resolve a signed URL at render via GET /v1/api/object-url — see Object URLs.

fileIdstringThe file_id UUID.
Returnsstring — `${cdnEndpoint}/objects/<uuid>`, i.e. https://cdn.zibnetwork.com/objects/<uuid> with the default constructor

Server-side Integration

The ZiB SDK runs in the browser. For server-side code (Node.js, Python, etc.) call the ZiB API directly with fetch or any HTTP client. All endpoints accept the same Bearer auth format.

Authentication

All authenticated endpoints accept a single header:

http
Authorization: Bearer <access_key>:<secret_key>

Copy your access key and secret key from the dashboard. Save the secret in your secret store when you create it: the dashboard can show it again today, but do not rely on that. Store them as environment variables (ZIB_ACCESS_KEY, ZIB_SECRET_KEY). No SigV4 signing is required.

Node.js example — initiate + upload

javascript
// Server: initiate the upload (credentials stay on your server)
const initRes = await fetch('https://api.mosnet.io/v1/api/upload/initiate', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    bucket: 'my-bucket',
    key: 'videos/intro.mp4',
    file_size: fileBuffer.length,
    content_type: 'video/mp4',  // forwarded to the CDN's Content-Type header
  }),
});
const registration = await initRes.json();
// registration = { upload_type, file_id, upload_url?, upload_id?, ... }
// (Additional opaque fields are returned — pass the entire object straight to the SDK.)

// Return 'registration' to the browser for ZiBStorage.uploadDirect().
// The browser sends the bytes — see "Where the bytes are uploaded from" below.

Python example — encode, resolve a URL, delete

python
import requests

API = "https://api.mosnet.io"
headers = {"Authorization": f"Bearer {ZIB_ACCESS_KEY}:{ZIB_SECRET_KEY}"}

# Start encoding (idempotent: an encoded or in-flight file returns its existing job)
res = requests.post(
    f"{API}/v1/api/encoding/request",
    headers={**headers, "Content-Type": "application/json"},
    json={"object_key": file_id, "transcription": "recommended"},
)
job = res.json()  # { encoding_id, status, ... } — status may be "already_encoded" / "already_queued"

# Track every stage from one place
pipeline = requests.get(f"{API}/v1/api/pipeline/{file_id}", headers=headers).json()

# Resolve a signed object URL at render time (poster, sidecar, any object you own)
url = requests.get(f"{API}/v1/api/object-url", params={"file_id": file_id}, headers=headers).json()["url"]

# Delete a file and every copy of it
requests.delete(f"{API}/v1/api/file/{file_id}", headers=headers)

The /s3/ path is deprecated

Do not build on the S3-compatible path (s3.zibnetwork.com/s3/…). It is deprecated and is closing for private data. Everything a tenant integration needs has a /v1/api/… route: upload with /v1/api/upload/initiate + uploadDirect, read through Object URLs, delete with DELETE /v1/api/file/{file_id}. The one gap is listing: there is no non-S3 “list my files” endpoint for access-key credentials today. It is coming; until then, record each file_id in your own database at upload time (the Quick Start already does). Authentication: the credential ZiB verifies is Authorization: Bearer access_key:secret_key. AWS SigV4 signing is not a supported way to authenticate to ZiB on any path — if you point an AWS SDK at /s3/, move it to Bearer calls on /v1/api/…. Bucket names are not globally unique (the four buckets we provision — video, posters, images, logos — collide with every other tenant’s), so an unauthenticated s3.zibnetwork.com/{bucket}/{key} read cannot tell whose bucket you mean. Never use that path as a public URL for your own assets.

Where the bytes are uploaded from

Uploads run in a browser today. The ZiB SDK is browser-only — it uses XMLHttpRequest, WebTransport, and the Web Crypto API, and there is no Node or Python upload library. Your server mints the registration; a browser sends the bytes.

In practice this splits cleanly, and it's the split most tenants want anyway — your credentials stay server-side while the file goes browser → node without a round trip through your infrastructure:

  • Your server — authenticates the user, then calls POST /v1/api/upload/initiate and returns the registration to that user's browser. Everything else on this page — triggering encoding, polling status, deleting files, receiving webhooks — is a plain HTTPS call from your backend with Bearer access_key:secret_key.
  • The browser — calls ZiBStorage.uploadDirect(file, registration), which encrypts and sends the chunks. This is the only step that must happen client-side.

If a file only ever exists on your server — a batch import, a scheduled job, a file received over an API — there is no supported upload path for it today. Talk to us about the use case rather than attempting to reproduce the encryption wire format; a mis-encrypted upload is accepted at write time and only fails later, when the CDN decrypts it and serves garbage.

Usage and billing reads

Storage usage and balance are dashboard-only. GET /v1/api/billing/usage, /billing/balance, and the /v1/api/me/* reads authenticate with a wallet session, not an access key — calling them with Bearer access_key:secret_key returns 401. There is no programmatic path to usage or spend figures right now; read them from the dashboard at app.zibnetwork.com.

How delivery egress is counted. One viewer fetching one item (a video segment or an object) is billed at most once per 10 minutes, however many requests they make for it in that time. A player that re-requests the same segment, or a client stuck in a retry loop, does not multiply the charge. A viewer is identified by their network address. ZiB may also apply an hourly cap on billed egress per tenant; delivery past it in that hour is not billed.

Scoped credentials

POST /v1/api/credentials/create (master Bearer access_key:secret_key) mints a short-lived credential limited to one bucket_prefix. Body: { permissions: ["READ"] | ["WRITE"], bucket_prefix, expires_in } (seconds, default 3600, 60–86400). Returns { access_key, secret_key, expires_at }.

  • READ means GET and HEAD only. Any write with a READ credential (PUT, POST, DELETE, upload initiate, PIN upload, file delete) is refused with 403 (“This credential is read-only”).
  • permissions is required (an empty list is refused with 400). Anything other than WRITE is read-only.

Hostnames & Content-Security-Policy

ZiB is moving its public hostnames from zibnetwork.com to mosnet.io. Both names serve the same paths today. URLs the backend generates — signed object URLs from /v1/api/object-url, the key and subtitle URIs inside HLS manifests, URLs in webhook and status payloads — now use cdn.mosnet.io (a URL ZiB stored before 2026-10-27 may still carry the old name). The API examples on this page use api.mosnet.io; api.zibnetwork.com is the same backend with the same credentials and keeps working, as do cdn.zibnetwork.com URLs. SDK 1.9.6 still defaults to the zibnetwork.com hosts (below). Storage-node hosts are still zb-<node>.zibnetwork.com — moving them is a later step. Retiring the old names is coming, with no date yet. They will not be switched off before this page and the changelog say so.

Pointing the SDK at mosnet.io

Optional — the defaults work. Every SDK entry point that talks to ZiB takes the endpoints as options:

javascript
const ZIB = { apiEndpoint: 'https://api.mosnet.io', cdnEndpoint: 'https://cdn.mosnet.io' };

await ZiBStorage.uploadDirect(file, registration, { onProgress, ...ZIB });
const blob = await ZiBStorage.fetchObject(signedUrlOrFileId, ZIB);
ZiBStorage.startTelemetry({ apiEndpoint: ZIB.apiEndpoint });
const ZiBLoader = ZiBStorage.createHlsLoader({ fileId, Hls, apiEndpoint: ZIB.apiEndpoint });

// Server-side instance (credentials — never in a browser):
const zib = new ZiBStorage({ accessKey, secretKey, ...ZIB });

Storage-node hosts are not configurable: they come from the backend, so zb-*.zibnetwork.com stays in your CSP either way.

Use every URL exactly as ZiB returns it. A returned or stored URL — hls_manifest_url, cdn_url, a signed object URL, a webhook payload URL — may carry either cdn.mosnet.io or cdn.zibnetwork.com, depending on when and where it was built. Both work. Do not rewrite the host: there is nothing to gain, and every hard-coded host is one more thing to change when the old name is retired. Allow both CDN hosts in your CSP for the same reason.

Hosts a browser page talks to

HostWhat forCSP directives
app.zibnetwork.comThe SDK script (/sdk/zib-sdk*.js).script-src
api.zibnetwork.com
api.mosnet.io
SDK calls from the browser: upload part/complete, /objects/{id}/serve-coords (fetchObject), /stream/{id}/holders (multi-source playback), telemetry. The SDK uses api.zibnetwork.com unless you pass apiEndpoint; list the one you use (or both).connect-src
cdn.mosnet.ioSigned object URLs (posters, sprites, sidecars, files), HLS manifests, keys and subtitle playlists.img-src, media-src, connect-src
cdn.zibnetwork.comThe same content under the old name: stored URLs, SDK defaults (cdn_url, the fetchObject fallback), manifests you load from it.img-src, media-src, connect-src
zb-*.zibnetwork.comStorage nodes: HLS segment bytes (direct from the SDK, or a CDN redirect), fetchObject holder reads, and the browser's encrypted upload PUTs.media-src, connect-src
s3.zibnetwork.comOnly if you still call the deprecated S3-path SDK methods — and those should not run in a browser.—

Your server's Bearer calls are not subject to your page's CSP. Redirects count: a player that follows a CDN redirect to a node is checked against the node host too, which is why zb-*.zibnetwork.com belongs in media-src as well as connect-src. Native HLS (Safari, iOS) loads through media-src; hls.js and the SDK's loaders load through connect-src.

http
Content-Security-Policy:
  script-src  'self' https://app.zibnetwork.com;
  img-src     'self' data: blob: https://cdn.mosnet.io https://cdn.zibnetwork.com;
  media-src   'self' blob: https://cdn.mosnet.io https://cdn.zibnetwork.com https://*.zibnetwork.com;
  connect-src 'self' https://api.zibnetwork.com https://api.mosnet.io https://cdn.mosnet.io https://cdn.zibnetwork.com https://*.zibnetwork.com;

blob: is there because fetchObject returns a Blob you will usually render through URL.createObjectURL, and hls.js plays through a MediaSource blob: URL. Add your own hosts and drop what you do not use.

WebTransport and the LAN fast path. discoverLocalNode() fetches http://localhost:8080/_/ping, and the co-located fast path opens a WebTransport session to the node's LAN address (https://<LAN IP>:<port>, from the webtransport hint). Both are governed by connect-src. A LAN IP cannot be listed in advance, so allowing it means connect-src https: http://localhost:8080 or similar. If your CSP blocks them, nothing breaks — the SDK treats it like any other WebTransport failure and uses the normal upload and read paths above.

Co-located Fast Path (LAN)

When a creator uploads from a browser on the same Wi-Fi as a ZiB Storage Node (typically their own laptop or desktop running the ZiB app with Gateway on), ZiB can route the upload directly to that node over WebTransport / QUIC, bypassing the Cloudflare tunnel entirely. Typical throughput goes from ~1.5 MB/s (residential CF tunnel ceiling) to 30–100 MB/s (LAN speed bounded by AES-GCM + disk write). For off-LAN creators the SDK falls back transparently to the existing multipart-over-CF path — no behaviour change.

This is an opt-in optimisation that requires three small wiring changes. Without them you get the default behaviour (the existing multipart-over-CF path), which is unchanged. The signal you got it right: DevTools Console goes silent during upload (WT happy path), or prints [ZiB] WebTransport upload unavailable, falling back to CF tunnel: … with a reason when the QUIC handshake can't open.

When this helps

  • A creator uploads from a browser on the same machine (or same Wi-Fi) as a ZiB node they own and run.
  • The win scales with file size — every upload is chunked, so even small files take this path, but the saving only becomes noticeable on larger ones.
  • Chromium-based browser (Chrome, Edge, Arc, Brave). Safari + Firefox lack WebTransport today and silently fall back.

1. Browser side — probe before initiating

Call ZiBStorage.discoverLocalNode() before asking your server to call /v1/api/upload/initiate. It pings http://localhost:8080/_/ping on the local gateway port and returns the node identity if one is reachable. Resolves in ~300 ms or to null.

javascript
// At the top of your upload handler:
const local = await ZiBStorage.discoverLocalNode().catch(() => null);
// local === { node_id: "ZB-XXX", wt_port: 4443 } | null

const res = await fetch('/api/upload-initiate', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    bucket,
    key,
    file_size,
    prefer_node: local?.node_id ?? null,   // ← this line
  }),
});
const registration = await res.json();
const result = await ZiBStorage.uploadDirect(file, registration, { onProgress });

The probe is cached per page load — calling it again is free. Pass { force: true } to re-probe (useful if the local node restarted mid-session and may have changed its ID).

2. Server side — forward the hint

In your /api/upload-initiate route, pass prefer_node through to ZiB's /v1/api/upload/initiate:

typescript
// app/api/upload-initiate/route.ts (Next.js)
export async function POST(req: Request) {
  const body = await req.json();
  const zibRes = await fetch('https://api.mosnet.io/v1/api/upload/initiate', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      bucket: body.bucket,
      key: body.key,
      file_size: body.file_size,
      prefer_node: body.prefer_node ?? undefined,   // ← this line
      pipeline: body.pipeline,
      // ...whatever else you already send
    }),
  });
  return new Response(await zibRes.text(), {
    status: zibRes.status,
    headers: { 'Content-Type': 'application/json' },
  });
}

ZiB's selector treats prefer_node as a soft preference — the hinted node wins if it's online and eligible, otherwise the existing free-space / freshness ranking takes over. If the hinted node has zero storage allocated (typical for a desktop ZiB app in transit-only mode) the capacity floor is bypassed too, because the node's shard server still refuses writes if it actually runs out of disk.

3. That's it

No SDK call changes needed. When the chosen node has a WebTransport listener published, the /initiate response now includes an optional webtransport: { host, port, cert_sha256 } field. uploadDirect() reads it automatically, opens a QUIC session pinned via serverCertificateHashes, and streams encrypted chunks LAN-direct. On any failure (off-LAN, UDP blocked, cert mismatch, no WebTransport API) it falls back to the existing multipart path with a diagnostic line in the console.

Browser compatibility & UX hints

The WT path runs in Chromium browsers today (Chrome, Edge, Arc, Brave, Opera). Safari and Firefox fall back silently — the upload still completes correctly over the existing multipart-CF path, just at the slower CF tunnel speed. The fallback prints a single line to the browser console ([ZiB] WebTransport upload unavailable…) but otherwise emits no UI signal. Tenants can optionally surface a hint to creators on fallback browsers so they understand why an upload is taking longer.

javascript
// Cheap pre-upload capability detection — no probes, no permissions.
const wtSupported = typeof WebTransport !== 'undefined';
const local = wtSupported
  ? await ZiBStorage.discoverLocalNode().catch(() => null)
  : null;

if (!wtSupported) {
  // Safari / Firefox / older Chromium. Optional UX hint.
  showToast('Tip: uploads are 10–30× faster in Chrome, Edge, or Arc.');
} else if (!local) {
  // Chromium but no co-located ZiB node reachable.
  // Either the creator isn't running a node, or it's on a different network.
  // No hint needed — they're on the normal path.
}

Don't block uploads on the browser check — fallback is a correctness path, not a failure mode. The toast pattern above is purely a courtesy. Most creators will never notice the difference because their upload still completes; the hint just sets expectations for creators who manage their own ZiB nodes and care about peak throughput.

macOS Local Network permission

On macOS Sequoia and later, the first time a browser tries to reach a non-localhost LAN target it triggers a system permission prompt. If the creator dismisses or misses it, that browser will silently fall back to CF tunnel until the user grants permission via System Settings → Privacy & Security → Local Network. The localhost:8080 probe used by discoverLocalNode() is exempt from this rule and always works.

Versions required

  • ZiB SDK ≥ 1.7.0 (the current release, 1.9.6, is served from app.zibnetwork.com/sdk/zib-sdk.js).
  • ZiB Storage Node ≥ v0.7.80 on the creator's machine.
  • ZiB backend ≥ 2026-05-24 deploy (already live in production).

Frequently asked

What if there's no local node? discoverLocalNode() returns null, you pass prefer_node: null, and ZiB picks a node by the existing rules. Upload proceeds over multipart-CF — same as before.

Does this leak the local IP? Only to the browser tab making the upload. The cert hash and WT host live inside the /initiate response that already needs to reach the browser anyway, and the LAN IP is what the node already advertises to peers on the same network.

Encryption guarantees? Identical. The SDK still AES-256-GCM-encrypts every chunk client-side before any byte leaves the browser. The node receives the same encrypted payload regardless of transport — WebTransport is just the pipe.

Upload Pipeline

A pipeline is a tenant-declared chain of stages that should run on a file after upload — encoding (HLS), transcription (SRT/VTT subtitles), and vision analysis (sidecar JSON). Declare the whole chain in a single field on POST /v1/api/upload/initiate and ZiB will (1) route the upload to a storage node that can fulfil every stage and (2) auto-enqueue each downstream stage as soon as the file finishes sharding. You poll a single endpoint — GET /v1/api/pipeline/{file_id} — to track every stage from one place.

The pipeline field replaces the older capabilities array. The legacy array is still accepted for backwards compatibility but new integrations should use pipeline. Pipeline declarations carry tier values (e.g. "recommended" vs "accurate") which the legacy array cannot express.

Pipeline shape

json
{
  "encode": true,
  "transcription": "fastest" | "recommended" | "accurate",
  "vision": "standard" | "hq",
  "webhook_url": "https://yourapp.com/api/zib-pipeline-event",
  "webhook_secret": "whsec_..."
}

Every field is optional. Omit a stage to skip it. The webhook fields are also optional — if provided, ZiB fires HMAC-signed events as each stage completes (in addition to the per-file file.ready webhook configured at the customer level).

Available stages

encode

HLS video transcoding (H.264 multi-bitrate). Required for all video uploads that will be streamed.

transcription

Speech-to-text transcription with three quality tiers. Produces SRT and VTT subtitle files served from the CDN.

vision

AI vision analysis with two quality tiers. Generates a sidecar JSON with scene understanding, ad targeting signals, IAB categories, and more.

Server-side pattern (recommended)

Declare the pipeline on your server when calling POST /v1/api/upload/initiate. The browser only receives the registration object — it never needs to know what jobs will run.

typescript
// server route — e.g. /api/upload-initiate
const res = await fetch('https://api.mosnet.io/v1/api/upload/initiate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${ACCESS_KEY}:${SECRET_KEY}`,
  },
  body: JSON.stringify({
    bucket: 'video',
    key: `videos/${contentId}/${videoType}-${Date.now()}-${fileName}`,
    file_size: fileSize,
    pipeline: {
      encode: true,
      transcription: 'recommended',
      vision: 'standard',
      // Thumbnails are ON by default — omit the field for defaults.
      // thumbnails: false  → opt out
      // thumbnails: { poster: { at: '10%' }, sprite: { interval: 5, width: 160 } }
      //                     → override individual fields
      // Vertical aspect-variant renditions for short-form / Shorts feeds.
      // OFF by default — omit the field for none. Current v1 accepts only ["9:16"].
      // aspect_variants: ['9:16']  → enable a 9:16 vertical rendition alongside
      //                              the main HLS. See "Aspect variants" below.
      webhook_url: 'https://yourapp.com/api/zib-pipeline-event',
      webhook_secret: process.env.WEBHOOK_SECRET,
    },
  }),
})

const registration = await res.json()
// Return registration to browser — credentials stay on the server
return Response.json(registration)

Polling pipeline status

Once the upload finishes, poll GET /v1/api/pipeline/{file_id} for the rolled-up status of every stage. The top-level status field advances through queued → uploading → encoding → ai_processing → complete (or failed). Stages that were not requested in the original declaration are returned as null.

json
{
  "file_id": "a1b2c3d4-...",
  "status": "ai_processing",
  "cdn_url": "https://cdn.mosnet.io/objects/a1b2c3d4-...?exp=...&tok=...",   // the uploaded FILE (signed), not the HLS stream
  "declared": { "encode": true, "transcription": "recommended", "vision": "standard" },
  "stages": {
    "upload":        { "status": "complete",
                       "phase": "distributed",
                       "replication_safe_at": null },
    "encoding":      { "status": "completed", "encoding_id": "...", "progress": 100,
                       "hls_manifest_url": "https://cdn.zibnetwork.com/stream/<file_id>/master.m3u8" },
                       // stored when the encode finished, so it can carry either host — both play
    "transcription": { "status": "running", "tier": "recommended", "progress": 40,
                       "output_file_id": null },
    "vision":        { "status": "queued", "tier": "standard", "progress": 0,
                       "output_file_id": null }
  },
  "error": null
}

The stages.upload block exposes three fields beyond status:

  • phase — finer-grained lifecycle: pending → uploading → distributed → safe (≥3 verified non-originator holders of the object) or cancelled (superseded by re-upload, or admin-cancelled). Authoritative for non-video uploads where there's no encoding job to read from.
  • replication_safe_at — ISO timestamp when the file became fully replication-safe. null until the safety sweep flips phase to safe.
  • cancelled_reason — present only when phase === 'cancelled'. Tenant-readable string (e.g. "superseded by re-upload to same bucket+object_key").

If stages.upload.phase === 'cancelled', the rolled-up status is forced to failed regardless of downstream stage state — superseded uploads are terminal even if encoding had partially run. Listen for the file.failed webhook event for an out-of-band signal.

Security note: Always declare pipelines from your server, never from the browser. Master credentials must not reach the browser — call /v1/api/upload/initiate from your backend, then pass the returned registration object to ZiBStorage.uploadDirect() in the browser.

Node selection

ZiB picks a storage node that supports every stage in your declaration — if you ask for vision, only nodes that have registered vision capability in the live capability table are eligible. If no online node can fulfil the full pipeline, the request fails with HTTP 503 immediately so you can fall back gracefully. Capability checks are always against the canonical capability registration, never a stale snapshot.

Upload via Node (PIN)

An alternative to the browser upload path. Instead of having the user's browser encrypt and PUT every byte through the network, you generate a single-use PIN that the user enters in their ZiB Node desktop app. The node app reads the file from local disk, encrypts it natively, shards it, and registers the file — all without the browser ever touching the bytes. After completion, your existing pipeline (encoding, transcription, vision) runs identically to a browser upload — the same file.ready and encoding.* webhooks fire, to the same URLs as for a browser upload (file.ready to your account-level URL only).

When to use this. Anyone whose source file already lives on (or near) a machine running a ZiB node. Creators uploading multi-GB videos go from ~30 minutes (slow upstream) to under a minute (local disk read + encrypt). Mobile users on bad cellular pipes can trigger the upload from their phone but the heavy lifting happens on a node at home. Anyone without a node falls back to the normal browser upload path — just keep both buttons.

Flow

  1. Your server calls POST /v1/api/upload/pin/create with the same params as /upload/initiate (bucket, key, pipeline). Returns { pin, session_id, expires_at }.
  2. You display the 3-word PIN to the user (and optionally a QR code).
  3. The user opens the ZiB Node desktop app, clicks Upload → Enter PIN, types the PIN, and chooses a file.
  4. The node app encrypts + shards the file locally and registers it with the network.
  5. Your server polls GET /v1/api/upload/pin/status/{session_id} until phase: "ready" (or use the SDK helper below).
  6. The same file.ready webhook fires, the encoding pipeline runs as normal, and you receive encoding.complete on your existing webhook URL (file.ready goes to the account-level URL only — see Webhooks).

Server: create a PIN

javascript
// Server-side only — your master Bearer credentials never reach the browser.
const res = await fetch('https://api.mosnet.io/v1/api/upload/pin/create', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}`,
  },
  body: JSON.stringify({
    bucket: 'video',
    key: 'videos/abc-123/main.mp4',
    pipeline: {
      encode: true,
      webhook_url: 'https://yourapp.com/api/zib-webhook',
      webhook_secret: process.env.ZIB_WEBHOOK_SECRET,
    },
  }),
});

const { pin, session_id, expires_at } = await res.json();
// pin → "mosaic-trinket-blanket"
// session_id → "5a6b862f-9bb6-4c5a-b5f3-df22bb9da959"
// expires_at → "2026-04-10T15:47:03Z"  (20 minutes from creation)

Server: poll for completion

javascript
// Poll until phase is 'ready' or 'failed'. The status endpoint returns
// a full lifecycle rollup so you can render byte-level upload progress,
// then encoding %, then analysis %, all from the same poll.
// SERVER-SIDE — uses your secret key.
async function waitForCompletion(sessionId, onProgress) {
  while (true) {
    const res = await fetch(
      `https://api.mosnet.io/v1/api/upload/pin/status/${sessionId}`,
      {
        headers: { 'Authorization': `Bearer ${ACCESS_KEY}:${SECRET_KEY}` },
      }
    );
    const s = await res.json();

    // Surface live progress to the UI. `phase` walks through:
    //   pending → redeemed → uploading → encoding → analyzing → ready
    // Each phase populates its own sub-block (defensive read with ?? null
    // so you don't break if a future phase is added).
    onProgress?.({
      phase: s.phase,
      uploadPercent:    s.upload?.progress_percent      ?? null,
      encodingPercent:  s.encoding?.progress_percent    ?? null,
      analysisStage:    s.analysis?.current_stage       ?? null,
      transcriptionPct: s.analysis?.transcription_progress ?? null,
      visionPct:        s.analysis?.vision_progress     ?? null,
    });

    if (s.phase === 'ready')   return { file_id: s.file_id };
    if (s.phase === 'failed' || s.phase === 'expired') {
      throw new Error(`Upload ${s.phase}: ${s.error_message ?? 'no detail'}`);
    }

    await new Promise(r => setTimeout(r, 2000));  // poll every 2s
  }
}

Lifecycle phases

The phase field on /pin/status walks through the full upload-and-pipeline lifecycle so a single button can reflect every stage. The legacy status field is still emitted at the top level for backwards compatibility — new integrations should read phase.

pendingPIN created, waiting for a node to redeem it.
redeemedA node has entered the PIN. The user is now picking a file.
uploadingNode is sending bytes to ZiB storage. Read upload.progress_percent for byte-level progress (updated ≥ every 2s).
encodingUpload landed. Encoder is building HLS variants. Read encoding.progress_percent.
analyzingEncoding done. Whisper / Qwen-VL is running. Read analysis.current_stage and the matching transcription/vision_progress.
readyEvery requested stage has finished. file_id is final. CDN URL is live.
failedUpload or a downstream stage failed. Get a new PIN to retry — the failed PIN is dead.
expiredPIN was not redeemed within 20 minutes. Get a new PIN.

Status response shape

json
{
  // Top-level rollup
  "phase": "encoding",                      // pending | redeemed | uploading | encoding | analyzing | ready | failed | expired
  "status": "uploading",                    // legacy field — keep reading 'phase' instead
  "redeemed_by_node_id": "ZB-899233",
  "file_id": "0c8e3765-9b2b-4a29-82cb-2f1911fe750c",
  "file_size_bytes": 1823450000,
  "error_message": null,
  "expires_at": "2026-04-15T15:47:03Z",

  // Populated while phase === "uploading" (omitted if no progress
  // report has landed in the last 30 seconds — node may have stalled)
  "upload": {
    "bytes_uploaded": 412300123,
    "total_bytes": 1823450000,
    "progress_percent": 22.6
  },

  // Populated when the pipeline declares encode: true
  "encoding": {
    "status": "encoding",                   // queued | encoding | complete | failed
    "progress_percent": 47.3
  },

  // Populated when the pipeline declares transcription and/or vision.
  // current_stage tracks the slowest path so a single bar reflects the
  // overall analysis progress.
  "analysis": {
    "current_stage": "transcription",       // "transcription" | "vision"
    "transcription_status": "running",      // null if not declared
    "transcription_progress": 42,           // 0–100, null if not declared
    "vision_status": "queued",              // null if not declared
    "vision_progress": 0                    // 0–100, null if not declared
  }
}
Update cadence. The redeeming node POSTs byte-level progress to the backend every ~2 seconds while uploading. Encoding and analysis progress come from the encoder/AI workers as they run. Polling more often than every 2 s buys you nothing — the underlying numbers don't move faster than that.
Single use, fail forward. Every PIN can only be redeemed once. If anything goes wrong — wrong PIN entered, upload aborted, network blip mid-shard, or the 20-minute timer expires — the PIN is permanently dead and your user has to request a new one. There is no /pin/extend or /pin/retry endpoint. In your UI, when the status becomes failed or expired, show a "Get a new PIN" button that calls /pin/create again. This keeps the security model simple and prevents replay attacks.

Node operator UX (optional context)

When the user enters your PIN in their ZiB Node app, the app shows a capability checklist comparing what your pipeline needs (encode / transcribe / vision) against what their node can do locally. Your tenant_settings.compute_mode setting controls what happens when their node can't do everything:

publicDefault. Any capable node on the network can run downstream jobs. Your user&apos;s node uploads, the network handles the rest.
prioritizePrefer your own user&apos;s nodes for compute, fall back to others if needed. The redeeming node will see a soft warning.
strictOnly your own user&apos;s nodes can run compute. If a third-party node redeems the PIN, the upload still completes but the encoding job will queue until one of your own nodes is online. The node UI shows an explicit warning.
Webhooks are unchanged. Whether the file arrived via browser upload or PIN upload, the same file.ready, encoding.queued, etc. webhooks fire to the same URLs with identical payloads (file.ready to the account-level URL only, never to a per-request webhook_url). Your webhook handler doesn't need to know which path was used. Route by file_id as always.

Video Encoding

After uploading a video file, call startEncoding(fileId) to trigger transcoding. ZiB produces an adaptive HLS stream with multiple quality variants, encrypts every segment, and distributes the segments redundantly across the storage network.

Encoding pipeline

1Transcode

Quality variants are produced: 360p, 480p, 720p, 1080p, and 2160p where source resolution allows. Each variant is a separate HLS stream.

2Encrypt segments

Every segment is encrypted before it leaves the encoding node, using a unique key generated per encoding job and held only by the ZiB backend.

3Distribute

Each quality variant is a single content-addressed unit (a root hash over its ordered, encrypted segments) and is replicated to at least three independent nodes across at least two failure domains — the same model as every ZiB file: a node holds a variant completely or does not hold it, so a player can fetch any segment from any holder. Every stored byte is re-verified against its content hash on a rolling schedule; a copy that fails is quarantined and re-replicated automatically.

4Manifest delivery

The CDN serves standard HLS manifests with the encryption key URL embedded. The player fetches the key once, decrypts segments locally, and streams from the storage network directly.

Playback

The output is a standard HLS stream that plays in any HLS-capable player (hls.js, Video.js, native Safari, AVPlayer, etc.). The CDN serves the master manifest and per-quality variant playlists at predictable URLs — the player handles encryption and adaptive bitrate switching automatically.

Triggering encoding

javascript
// SERVER-SIDE: `storage` is new ZiBStorage({ accessKey, secretKey }) on your server.
// After the browser's uploadDirect() completes and sends you the file_id:
const job = await storage.startEncoding(fileId);
console.log(job.encoding_id); // use this to poll status

// Poll until complete
const status = await storage.getEncodingStatus(job.encoding_id);
// status.hls_manifest_url — AES-128 encrypted HLS, plays in any HLS player
Listen for the encoding.complete webhook event instead of polling — it fires as soon as the HLS manifest is ready and includes the manifest URL in the payload.

Tracking encoding progress

Poll for this — there is no progress webhook. This page previously described an encoding.progress event throttled to 10s / 5% deltas. It is not emitted. Nothing in the backend fires it, so a UI driven from it sits at 0% from encoding.started until encoding.complete and never errors. Use GET /v1/api/pipeline/{file_id} instead: it returns a 0–100 integer for the encode stage and works for every stage of the pipeline, not just encoding.

javascript
// Progress: poll while your UI is open (there is no progress webhook).
const p = await fetch(`https://api.mosnet.io/v1/api/pipeline/${fileId}`, {
  headers: { Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}` },
}).then((r) => r.json());
const encodingProgress = p?.stages?.encoding?.progress ?? 0;   // 0–100

// Completion: the encoding.complete webhook does fire.
app.post('/api/zib-webhook', async (req, res) => {
  const { event, file_id } = req.body;   // verify X-ZiB-Signature first — see Webhooks

  if (event === 'encoding.complete') {
    // Final state — manifest URLs are now live
    await db.update('videos', {
      encoding_progress: 100,
      encoding_status: 'complete',
      hls_url: req.body.hls_manifest_url,
    }, { where: { file_id } });
  }

  res.sendStatus(200);
});
Reset on re-upload — and the file_id has changed. A new upload to a (bucket, object_key) you've seen before produces a new file_id. The file.ready webhook arrives carrying the new one. Your stored zib_file_id, zib_encoding_id, CDN URL, and any progress state from the previous upload are all stale and must be replaced — not just progress reset to 0. ZiB cancels the prior encoding job. Whether a new encode starts by itself depends only on the new upload: if its /upload/initiate declared pipeline.encode: true, ZiB enqueues the encode when the upload completes (see Upload Pipeline). If it declared no pipeline, nothing is guaranteed to start: your handler must call /v1/api/encoding/request for the new file_id, exactly as it did for the first upload (Pattern A below). Calling it as well is safe either way — it answers already_queued.
No pipeline declared means no encode. The platform default pipeline applies only to ZiB Studio creators' own video folders. A folder you create is never given it, whatever its name, including video. To encode on upload, declare pipeline.encode: true, or call /v1/api/encoding/request.

Edge cases

  • Monotonic bars — use Math.max(current, polled) if you want progress that never steps back between polls.
  • Fast encodes — small files or hardware-accelerated encodes may go from 0 to 100 between two polls. The bar fills instantly — this is expected.
  • Webhooks can overtake polls — encoding.complete may arrive before your next poll shows 100. Treat the webhook as final.

Polling encoding status correctly

If you can't accept webhooks (or want a fallback alongside them), poll GET /v1/api/pipeline/<file_id> or GET /v1/api/encoding/jobs/<encoding_id> with a small interval (2–5 seconds). Four things to get right:

  1. Null-guard the response. Between the moment your /v1/api/encoding/request returns and the moment the encoding job row materialises (typically <1 s), the poll endpoint can return an empty body. Use result?.phase, not result.phase — otherwise your first poll throws a TypeError.
  2. Null-guard the closure inputs, too. If your interval/setTimeout captures variables that may flip to null (React state on rapid navigation, a cleared store), check them on every tick — not just at setup. A common shape: if (!content?.id) return; as the first line of the interval callback. A closure that was alive when the fetch was scheduled can fire after the component unmounts or refetches, with stale references that have since been cleared. If you have more than one poller in the same file, make sure every one has the guard — it's easy to add it to the obvious one and miss a sibling that takes the same code path.
  3. Stop on the terminal top-level status. GET /v1/api/pipeline/{file_id} returns a top-level status — poll that, not a per-stage field:
    • status === 'complete' — success. The HLS manifest is live at data.stages.encoding.hls_manifest_url (always /stream/{file_id}/master.m3u8). data.cdn_url is a signed URL for the uploaded source file, not the stream.
    • status === 'failed' — terminal failure. Read data.error; show a Retry button.
    • In-progress values are queued | uploading | encoding | ai_processing — keep polling.
    To detect a re-upload that superseded this file, check data.stages?.upload?.phase === 'cancelled' and switch your UI to the new file_id.
  4. On /pipeline, use the top-level status. The /pipeline/{file_id} rollup exposes status at the top level and a phase only on stages.upload — there is no stages.encoding.phase, and status never returns safe. (The phase vocabulary — safe, failed_user_action — belongs to a different endpoint, GET /v1/api/my-jobs.)
  5. Backoff sensibly. 2–5 second poll intervals are plenty. Encoding takes seconds to minutes; tighter polling just burns your rate-limit budget without changing the outcome.
javascript
async function waitForEncode(fileId, { timeoutMs = 10 * 60_000, intervalMs = 3_000 } = {}) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const res = await fetch(`https://api.mosnet.io/v1/api/pipeline/${fileId}`, {
      headers: { Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}` },
    });
    if (!res.ok) {
      // Transient network / backend error — log and retry next tick.
      await sleep(intervalMs);
      continue;
    }
    const data = await res.json();

    // 1. Null-guard: the /pipeline rollup may be sparse during the brief
    //    window between the initiate call and the job row being created.
    const status = data?.status;   // top-level rollup status
    if (!status) {
      await sleep(intervalMs);
      continue;
    }

    // 2. Terminal: success. The HLS manifest is live.
    if (status === 'complete') {
      // NOT data.cdn_url — that is a signed URL for the uploaded source file.
      return { ok: true, manifestUrl: data.stages?.encoding?.hls_manifest_url };
    }

    // 3. Superseded by a re-upload to the same (bucket, key)? Check this
    //    BEFORE 'failed': /pipeline maps 'cancelled' to failed at the top
    //    level, so the upload stage's own phase is what tells them apart.
    if (data?.stages?.upload?.phase === 'cancelled') {
      return { ok: false, error: 'Upload superseded; check for the new file_id' };
    }

    // 4. Terminal: failure.
    if (status === 'failed') {
      return { ok: false, error: data.error ?? 'Encoding failed' };
    }

    // In-progress: queued | uploading | encoding | ai_processing — keep polling.
    await sleep(intervalMs);
  }
  return { ok: false, error: 'Timed out waiting for encode' };
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

Webhooks are still the preferred path — your server doesn't pay for the polling round-trips and you react instantly when the state changes. Polling is the right tool when your environment can't accept inbound HTTPS (a background worker, a CLI script, a deployment behind a firewall without an ingress).

Playing the stream

html
<!-- hls.js (browser) — pin a version in production -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<video id="video" controls></video>
<script>
  const hls = new Hls();
  hls.loadSource('https://cdn.mosnet.io/stream/<file_id>/master.m3u8');
  hls.attachMedia(document.getElementById('video'));
</script>

<!-- Native HLS (Safari / iOS) -->
<video controls src="https://cdn.mosnet.io/stream/<file_id>/master.m3u8"></video>

cdn.zibnetwork.com/stream/<file_id>/master.m3u8 serves the same stream. Either URL is safe to store: the manifest is generated per request and signs the key URI inside it, so it never goes stale. Allow the hosts in Hostnames & CSP if your site sends a Content-Security-Policy.

Multi-source playback with the SDK (optional)

The plain player setup above works on its own. The SDK can also fetch segments for you, pulling consecutive segments from several holder nodes at once, checking each one against its BLAKE3 content hash before the player sees it, and switching to the next holder on an error or timeout. If anything fails it falls back to the normal CDN path, so adding it cannot break playback. Pick the helper that matches your player:

html
<script src="https://app.zibnetwork.com/sdk/zib-sdk-1.9.6.js"></script>

<!-- hls.js: a fragment loader (works with hls.js workers) -->
<script>
  const ZiBLoader = ZiBStorage.createHlsLoader({ fileId, Hls });
  const hls = new Hls({ fLoader: ZiBLoader });
  hls.loadSource(`https://cdn.mosnet.io/stream/${fileId}/master.m3u8`);
  hls.attachMedia(videoEl);
  ZiBStorage.attachToVideo(videoEl, { fileId });   // playback telemetry
</script>

<!-- video.js / VHS -->
<script>
  const off = ZiBStorage.installVhsMultiSource({ fileId, videojs });   // call off() on teardown
</script>

<!-- Any other MAIN-THREAD player that loads segments by XHR (Bitmovin, shaka, …) -->
<script>
  const off = ZiBStorage.installGlobalMultiSource({ fileId }); // patches XMLHttpRequest.prototype
</script>
  • installGlobalMultiSource patches the page's global XMLHttpRequest — one title per page, and it cannot reach a player that fetches in a Web Worker (use createHlsLoader for hls.js).
  • The helpers call GET https://api.zibnetwork.com/stream/{file_id}/holders and then read segment bytes from zb-*.zibnetwork.com nodes — both must be in connect-src.
  • The video.js and global helpers also keep the AES-128 key in memory for up to its Cache-Control max-age (30 s), so expect about one key request per 30 s of playback rather than one per segment (since 1.9.2). hls.js caches keys itself.
  • video.js 8 needs SDK 1.9.6 or later for installVhsMultiSource. In every release before 1.9.6 (reproduced on 1.9.1–1.9.5) the helper did not play on video.js 8: it did not fill in the response the way video.js 8's VHS reads it, so every key and segment it answered failed and playback never started. No API change; move to https://app.zibnetwork.com/sdk/zib-sdk-1.9.6.js.
  • Options (parallel, verify, prefetchCap, keyCacheMaxMs, …) are listed in the multi-source guide and the SDK source.

Quality menu for video.js (optional, SDK 1.9.6+)

The SDK can add a rendition menu (“Auto (720p)”, 1080p, 720p, …) to a video.js 8 player. It is opt-in: nothing changes unless you call it. Three helpers:

  • ZiBStorage.videojsHtml5Options(html5?) returns the html5 options to create the player with. On desktop browsers whose Media Source Extensions can play ZiB's codecs it adds vhs.overrideNative: true, nativeAudioTracks: false and nativeVideoTracks: false (video.js needs the three together). It merges into your own options without changing them. On iPhone and iPad (including iPadOS, which reports itself as a Mac), and wherever MSE is missing, it returns your options unchanged.
  • ZiBStorage.installQualityMenu(player, { videojs, storageKey? }) adds the button before the fullscreen toggle and returns an uninstall function. Call it in player.ready. The button stays hidden until the title has at least two renditions. Picking a rendition pins it; Auto restores adaptive switching. The viewer's pick is remembered in localStorage under zib.player.quality (storageKey: 'other-key' renames it, storageKey: false turns it off). A remembered pick the title does not have falls back to Auto. Needs video.js 8 (it uses video.js's bundled quality levels).
  • ZiBStorage.shouldRetryNativeHls(errorCode, html5, alreadyRetried) returns true only for error code 4 (MEDIA_ERR_SRC_NOT_SUPPORTED) while your options forced VHS, and only once. When it does, dispose the player and create it again with your plain options. The worst case is then no menu, never no video.
javascript
const plain = { vhs: { limitRenditionByPlayerDimensions: true } };  // your own html5 options
let retried = false;
let player, offQuality;

function mount(html5) {
  const el = document.createElement('video-js');   // dispose() removes the element, so each mount gets its own
  container.appendChild(el);
  player = videojs(el, { controls: true, html5, sources: [{ src, type: 'application/x-mpegURL' }] });
  player.ready(() => { offQuality = ZiBStorage.installQualityMenu(player, { videojs }); });
  player.on('error', () => {
    if (ZiBStorage.shouldRetryNativeHls(player.error()?.code, html5, retried)) {
      retried = true;
      setTimeout(() => {  // let the other error listeners finish before disposing
        offQuality?.();
        player.dispose();
        mount(plain);     // once more, with your plain options (on Safari: native HLS, no menu)
      }, 0);
    }
  });
}
mount(ZiBStorage.videojsHtml5Options(plain));

// teardown
offQuality?.();
player.dispose();
  • Order. installQualityMenu needs the player, so call it in player.ready, for each player you create. installVhsMultiSource is global, not per player: video.js reads it on every request, so install it once per title, before the player loads (as in Multi-source playback), and keep it across the remount. The two do not depend on each other. On native HLS (iPhone, iPad, and Safari after the remount) video.js's own engine is not used, so multi-source does not engage there; playback is unaffected.
  • Publisher sessions: reuse the token. The remount plays the same file_id, so keep the session token you already have: it is valid for that file until its expires_at. The SDK helpers capture the token when you call them (installVhsMultiSource({ fileId, sessionToken }), attachToVideo(videoEl, { fileId, sessionToken })), so a newly minted token would mean calling them again with it; otherwise they keep reporting the old session.
  • If you use attachToVideo, it is per element: call its detach() before dispose(), and attach again to the new player's video element with the same fileId and token.
Desktop Safari shows the menu only if you create the player with ZiBStorage.videojsHtml5Options(…). A player built without overrideNative plays Safari's native HLS, which exposes no list of renditions, so it gets no menu. iPhone and iPad always use native HLS, so they never show the menu; that is by design.
Trade-off: no AirPlay on desktop Safari with these options. The video then plays through Media Source Extensions from an in-page blob: source, which an Apple TV cannot fetch, so Safari offers no AirPlay for it. iPhone and iPad keep AirPlay. Screen mirroring still works.

Re-encoding the same file

POST /v1/api/encoding/request is idempotent. If you request encoding for a file_id that already has a completed encoding job whose segments are live on the CDN, ZiB will not start a second encode — you get back the existing job's ID and CDN URL so your code can carry on as if it had just queued one:

json
// Response when the file is already encoded
{
  "encoding_id": "771a1e5e-5e29-470a-9f1f-5ad8b999efc4",
  "status": "already_encoded",
  "file_id": "01846192-315c-4f0a-b766-12b18a26b209",
  "cdn_url": "https://cdn.mosnet.io/stream/01846192-315c-4f0a-b766-12b18a26b209/master.m3u8",
  "message": "File is already encoded by an existing job. Pass { \"force\": true } in the request body to deliberately re-encode."
}

The same shape applies while a job is mid-flight: you get status: "already_queued" with the in-flight job's ID. Branch on status to decide whether to poll progress, fire your own "already done" UI, or just record the encoding_id and move on.

Forcing a re-encode

When you genuinely want a fresh encode of the same source bytes (changed encoding presets, repaired segments, etc.) pass force: true in the request body:

javascript
// SERVER-SIDE — uses your secret key.
await fetch('https://api.mosnet.io/v1/api/encoding/request', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${ZIB_ACCESS_KEY}:${ZIB_SECRET_KEY}`,
  },
  body: JSON.stringify({
    object_key: fileId,        // existing file_id
    force: true,               // override the idempotency gate
    webhook_url: WEBHOOK_URL,  // optional
  }),
});
The CDN URL never changes. A re-encode produces new segments and a new HLS key under the hood, but cdn.mosnet.io/stream/{file_id}/master.m3u8 continues to serve playable content throughout — the old encode keeps serving until the new one replaces it. You do not need to swap URLs in your app. Listen for encoding.complete on the new encoding_id to know the new encode is live.

Why this matters (the bug we closed)

Before 2026-05-12 the endpoint would happily create a new encoding job every time it was called, even when the file was already encoded. Every job has its own random AES-128 key for HLS segment encryption, so a file with N completed jobs ended up with N different keys floating in the database. The CDN's key-lookup picked one of them arbitrarily and the wrong key was sometimes served — which lenient players (hls.js, Bitmovin, Video.js) silently retried or skipped past, but Safari native HLS / AVPlayer correctly rejected with a black screen and no error.

Two fixes shipped together: (1) the CDN now always picks the key whose job has live segments, so the bad state cannot produce a wrong-key symptom even when it accidentally happens, and (2) this endpoint refuses to create the bad state in the first place unless you pass force: true.

Tenant-side hygiene we recommend

  • Treat /v1/api/encoding/request as idempotent — calling it twice for the same file_id is safe and cheap.
  • Don't add your own retry loop on top. If the response is already_queued or already_encoded, store the returned encoding_id and rely on webhooks / status polling.
  • Guard your tenant-side "Re-encode" UI behind auth and a server-side dedup check on the in-flight job. Without it, a fast double-click or a curl loop will fire many requests; ZiB will collapse them server-side but you'll waste roundtrips and confuse your own UI.
  • The file.ready webhook fires exactly once per upload today. If we ever add at-least-once retry semantics, your handler should already be idempotent: check whether you've already kicked off encoding for that file_id before calling startEncoding.

Thumbnails

Every encode produces a poster frame and a WebVTT-driven sprite sheet for scrub-bar previews. Default poster is grabbed at 10% of duration (clamped to 1–10s) so cold-opens and fade-ins don't become the cover image. Default sprite cadence is one tile every 5 seconds at 160px wide, packed into 10×10 grid JPEGs — the WebVTT references each tile with #xywh=x,y,w,h so Bitmovin, hls.js, Video.js and shaka-player pick it up natively. Defaults are ON; pass thumbnails: false to opt out.

javascript
// All four shapes are valid on POST /v1/api/encoding/request:

// 1. Omit the field — thumbnails generated with defaults.
{ object_key: fileId }

// 2. Explicit defaults.
{ object_key: fileId, thumbnails: true }

// 3. Opt out entirely.
{ object_key: fileId, thumbnails: false }

// 4. Override individual fields (any subset; missing keys fall back to defaults).
{
  object_key: fileId,
  thumbnails: {
    poster: { at: "10%" },            // or "5s" / "Ns" for an absolute timestamp
    sprite: { interval: 5, width: 160 }
  }
}

When thumbnails are produced, the thumbnails object is surfaced in three places — identical shape on each, so tenants who can't receive webhooks (localhost dev, replayed deliveries, missed events) can still pick the URLs up by polling:

  • The encoding.complete webhook payload (top-level field).
  • GET /v1/api/pipeline/{file_id} → stages.encoding.thumbnails.
  • GET /v1/api/encoding/jobs/{encoding_id} → thumbnails (top-level).

The field is omitted (or null) until the encoder has registered the artifacts — typically a few seconds after phase reaches local_complete. Tenants who opt out (thumbnails: false) will see it stay null forever; that's the contract.

json
// thumbnails block — same shape on webhook + both polling endpoints.
// Omitted (or null) when the tenant opted out or before registration completes.
{
  "thumbnails": {
    // URLs are signed and expire. Use them now; do not store them.
    "poster_url":         "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=...",
    "sprite_url":         "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=...",
    "sprite_vtt_url":     "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=...",
    // Ids are stable forever. THESE are what you persist.
    "poster_file_id":     "<uuid>",
    "sprite_file_id":     "<uuid>",
    "sprite_vtt_file_id": "<uuid>"
  }
}
The _file_id fields were added on 2026-08-19 after a tenant reported parsing UUIDs back out of the URLs in order to store the stable half. That parsing breaks now the URLs carry a query string — take the ids from these fields instead.

Use poster_url directly as the <video poster="..."> attribute or as the cover image in any thumbnail grid. The sprite-sheet VTT plugs into the major HLS players verbatim:

javascript
// Bitmovin
player.load({
  hls: hlsManifestUrl,
  poster: thumbnails.poster_url,
  tracks: {
    thumbnailTrack: {
      file: thumbnails.sprite_vtt_url
    }
  }
});

// hls.js — load the VTT yourself and feed it to your seekbar component:
const vtt = await fetch(thumbnails.sprite_vtt_url).then(r => r.text());
// Each cue body is "<sprite_sheet_url>#xywh=x,y,w,h" — render that tile into your scrubber.

// Video.js (vtt-thumbnails plugin)
player.vttThumbnails({ src: thumbnails.sprite_vtt_url });
Idempotency and re-encodes. Like the rest of the encoding pipeline, thumbnails are bound to the live encoding job. Calling POST /v1/api/encoding/request a second time for a file that already has a complete encode returns already_encoded and does not regenerate thumbnails — the existing CDN URLs keep serving. To regenerate (e.g. after changing the poster timestamp), pass force: true alongside your new thumbnails config.

When to opt out

Defaults are tuned for long-form, scrubbable video — the kind of content where a viewer hovers the timeline to preview frames. If your pipeline doesn't render a seek bar or doesn't need a cover image, opt out and save the ~1-2 s of encoder wall time plus the storage:

  • Ad creatives, pre-rolls, short transactional clips — no scrub UI, host system usually already has its own preview thumbnail. Opt out with thumbnails: false.
  • Audio-only or visualizer content — sprite-sheet previews of a static waveform aren't useful. Opt out.
  • Long-form video / VOD, courses, tutorials — leave the defaults on. The sprite VTT feeds Bitmovin / hls.js / Video.js scrub-bar previews with zero extra integration code.
  • UGC where you display a cover image but don't scrub — current API is all-or-nothing per encode; opt out with thumbnails: false and reuse whatever cover image your upload flow already produces. (We can add a poster-only mode if enough integrations need it — open a ticket.)

Aspect variants

Generate a vertical 9:16 HLS rendition alongside the main horizontal encode — for Shorts shelves, TikTok-style vertical feeds, iPhone portrait playback. Each rendition is a full multi-bitrate HLS ladder with its own poster, content-encrypted with a per-rendition AES-128 key, and served from its own CDN stream URL. Defaults are OFF; opt in by passing aspect_variants: ["9:16"]. v1 accepts only 9:16; the field is an array so adding 4:5 / 1:1 / 4:3 later is a non-breaking extension.

The rendition is produced with a center crop (no smart-crop in v1) and an adaptive ladder derived from your source — rungs whose bitrate would exceed the source's are dropped automatically, so a 720p source produces a 720p-and-below vertical ladder, not an upscaled 1080p one.

javascript
// 1. Inline mode — declared at upload time, runs alongside the main encode.
//    Add to the same `pipeline` block you pass to /v1/api/upload/initiate.
{
  bucket: 'video',
  key: 'videos/abc.mp4',
  file_size: 1234567,
  pipeline: {
    encode: true,
    aspect_variants: ['9:16'],          // ← new
    webhook_url: 'https://...',
    webhook_secret: process.env.WEBHOOK_SECRET,
  }
}

// 2. Inline mode via /v1/api/encoding/request (same root-level field).
{ object_key: fileId, aspect_variants: ['9:16'] }

// 3. Backfill mode — produce variants for a file that already has a complete
//    horizontal encode, WITHOUT re-running the main pass. Cheaper than force:true.
{ object_key: fileId, aspect_variants_only: ['9:16'] }
//
// Response: { status: 'queued', mode: 'aspect_variants_only',
//             parent_encoding_job_id: '...', child_encoding_job_ids: ['...'] }
//
// Mutually exclusive with force:true and with aspect_variants on the same call.

Once each variant is registered, the aspect_variants object is surfaced in the same three places as thumbnails — keyed by ratio string:

  • The encoding.complete webhook payload (top-level field).
  • GET /v1/api/pipeline/{file_id} → stages.encoding.aspect_variants.
  • GET /v1/api/encoding/jobs/{encoding_id} → aspect_variants (top-level).

Each ratio entry is omitted from the object entirely until the encoder has registered it (typically a few seconds after the main encode reaches local_complete). If a variant encode fails the ratio is simply absent from the response; the parent encode still completes. The entire aspect_variants field is omitted when the tenant didn't opt in.

json
// aspect_variants block — same shape on webhook + both polling endpoints.
{
  "aspect_variants": {
    "9:16": {
      "hls_manifest_url": "https://cdn.mosnet.io/stream/<vertical_file_id>/master.m3u8",
      "poster_url":       "https://cdn.mosnet.io/objects/<vertical_poster_id>?exp=...&tok=..."
    }
  }
}

The hls_manifest_url is a standard HLS stream — point any player at it the same way you would the horizontal manifest. The vertical rendition has its own file_id and its own AES-128 key (served from /stream/<vertical_file_id>/key), so it's independently routable, cacheable, and replaceable without touching the main encode.

javascript
// Bitmovin — switch between horizontal and vertical based on viewport
const horizontal = encodingResult.hls_manifest_url;
const vertical   = encodingResult.aspect_variants?.['9:16']?.hls_manifest_url;
const verticalPoster = encodingResult.aspect_variants?.['9:16']?.poster_url;

player.load({
  hls: window.innerHeight > window.innerWidth ? (vertical || horizontal) : horizontal,
  poster: window.innerHeight > window.innerWidth
    ? (verticalPoster || encodingResult.thumbnails?.poster_url)
    : encodingResult.thumbnails?.poster_url,
});

// React Native / iOS-native — pick the vertical rendition directly for a portrait feed.
const shortsUrl = videoRow.aspect_variants?.['9:16']?.hls_manifest_url;
if (shortsUrl) {
  // Render a vertical scrolling feed using AVPlayer / ExoPlayer.
}
Backfill an existing library. Calling POST /v1/api/encoding/request with aspect_variants_only: ["9:16"] against a file that's already encoded queues the variant on top of the existing parent — no force:true, no main re-encode. Ideal for adding a Shorts shelf to a library of horizontal videos. The response gives you the parent_encoding_job_id + a list of child_encoding_job_ids, one per requested ratio; webhook / polling surfaces the URLs the moment each child finishes.

When to opt in

  • Shorts shelf / vertical feed on mobile — opt in. Native portrait playback without forcing the player to letterbox / pillarbox horizontal content.
  • Trailers, hero videos, social-share clips — opt in if you embed those on a vertical scrolling surface.
  • Long-form VOD where the viewer is always landscape — leave it off. Vertical rendition costs encoder time and storage; horizontal already covers the use case.
  • Ad creatives, pre-rolls, square / portrait sources — usually unnecessary. If the source is already vertical or square, the horizontal HLS is the only rendition you need.
v1 limits. Center-crop only (smart-crop deferred). The crop is taken from the source center on every frame — if the focal subject is hard-pinned to the left or right edge, parts of it can be lost on a vertical view. For most trailer / hero / social content the center is the right answer. If smart-crop becomes a hard requirement, open a ticket so we can prioritise.

Pattern A — webhook-triggered encode

For the standard tenant upload flow, you typically kick off encoding the moment ZiB delivers file.ready. The shape below is what we recommend for any handler that receives file.ready and decides to start an encode. The idempotency check at step 3 is the most important part — duplicate webhook deliveries should be cheap no-ops, not double-fires.

typescript
// app/api/zib-file-ready/route.ts (Next.js App Router style)

export async function POST(req: Request) {
  const event = await req.json();

  // 1. Verify the webhook signature (mandatory — see Webhooks).
  if (!verifyZibSignature(req.headers.get('x-zib-signature'), event)) {
    return new Response('invalid signature', { status: 401 });
  }

  if (event.event !== 'file.ready') {
    return Response.json({ received: true, ignored: true });
  }
  const { file_id, bucket, object_key } = event;

  // 2. Look up your local row by the STABLE identifier — (bucket, object_key).
  //    (That is YOUR lookup. The encode request below sends the file_id.)
  //
  //    Do NOT look up by file_id. A re-upload to the same key produces a
  //    NEW file_id, so the row you stored against the prior file_id will
  //    not match and your handler will silently no-op while the user's
  //    UI sits at "Queued — waiting for an encoder" forever.
  const creative = await db.creatives.findUnique({
    where: { bucket_object_key: { bucket, object_key } },
  });
  if (!creative) return Response.json({ received: true, matched: false });

  // 3. Idempotency keyed on file_id — NOT on encoding_status.
  //
  //    - If creative.zib_file_id === event.file_id and we already stored an
  //      encoding_id for it, this is a duplicate webhook delivery → skip.
  //    - If they differ, the user re-uploaded. The stored encoding belongs
  //      to the OLD file and is now cancelled by ZiB; we must fire a fresh
  //      encoding request for the NEW file_id.
  //
  //    A common bug — and the one that causes "Queued forever" deadlocks —
  //    is keying this check on `encoding_status !== 'ERROR'`. That returns
  //    "already handled" for every re-upload whose previous encode succeeded,
  //    so the new upload is never encoded. The user sees their re-upload
  //    "queued" indefinitely and ZiB never receives an encoding request.
  if (creative.zib_file_id === file_id && creative.zib_encoding_id) {
    return Response.json({ received: true, matched: true, deduped: true });
  }

  // 4. Fire encoding request. ZiB is also idempotent server-side — if
  //    another code path already fired for this file_id you'll receive
  //    status: 'already_queued' (in-flight job exists) or
  //    status: 'already_encoded' (completed job exists). Treat both the
  //    same — record the encoding_id and trust webhooks for the rest.
  const res = await fetch('https://api.mosnet.io/v1/api/encoding/request', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}`,
    },
    body: JSON.stringify({
      object_key: file_id,        // canonical form: the NEW file_id from this event
      // webhook_url and webhook_secret are optional here — if omitted, ZiB uses
      // your account's job-event default (encoding_webhook_url).
    }),
  });
  if (!res.ok) {
    return new Response(`ZiB encode request failed: ${res.status}`, { status: 502 });
  }
  const job = await res.json();

  // 5. Persist the new file_id AND the new encoding_id together. Writing
  //    file_id is the critical step that lets future webhook deliveries
  //    recognise this row as up to date — without it, the next file.ready
  //    for this key will look like another re-upload and you'll loop.
  await db.creatives.update({
    where: { id: creative.id },
    data: {
      zib_file_id:     file_id,                  // overwrite prior file_id
      zib_encoding_id: job.encoding_id,
      cdn_url:         job.cdn_url ?? job.hls_manifest_url ?? null,
      encoding_status:
        job.status === 'already_encoded' ? 'COMPLETE' : 'ENCODING',
    },
  });

  return Response.json({
    received: true,
    matched: true,
    encoding_id: job.encoding_id,
    status: job.status,
  });
}

Pattern B — user-triggered re-encode

For a "Re-encode" button in your own UI (encoder settings changed, source corrupted, etc.), the call shape is the same plus force: true. The three things you MUST add on your side — none of which ZiB can enforce for you — are: auth on the endpoint, authorization (the caller owns the content), and a server-side dedup check against your own DB. The UI's in-flight lock is not enough; a page reload or a second tab bypasses it.

typescript
// app/api/re-encode/route.ts

import { getServerSession } from '@/lib/auth';

export async function POST(req: Request) {
  // 1. Authentication. Refuse anonymous callers.
  const session = await getServerSession(req);
  if (!session?.user) {
    return new Response('unauthenticated', { status: 401 });
  }

  const { content_id, video_type } = await req.json();
  //    video_type: 'main' | 'trailer'

  // 2. Authorization. Caller must own this content (or be admin).
  const content = await db.content.findUnique({ where: { id: content_id } });
  if (!content) return new Response('not found', { status: 404 });
  if (content.owner_id !== session.user.id && !session.user.isAdmin) {
    return new Response('forbidden', { status: 403 });
  }

  const statusKey = `${video_type}_encoding_status`;
  const idKey     = `${video_type}_zib_encoding_id`;
  const fileKey   = `${video_type}_source_file_id`;

  // 3. Server-side dedup. Refuse if an encode is already in flight for
  //    this content+video_type. The UI lock isn't enough — page reload
  //    clears it, a second tab bypasses it, a scripted POST loop wouldn't
  //    even render the UI.
  if (['QUEUED', 'ENCODING'].includes(content[statusKey])) {
    return Response.json(
      { error: 'already_in_progress', encoding_id: content[idKey] },
      { status: 409 },
    );
  }

  // 4. Fire ZiB with force=true. The old encoded segments keep serving
  //    the CDN URL until the new encode completes, so playback isn't
  //    interrupted. Do NOT delete the prior encoded data first — that
  //    creates a window where the file has no playable output if the
  //    new encode never starts (e.g. ZiB returns an error). Let ZiB
  //    do the cutover atomically once segments land.
  const res = await fetch('https://api.mosnet.io/v1/api/encoding/request', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}`,
    },
    body: JSON.stringify({
      object_key: content[fileKey],
      force: true,
    }),
  });
  if (!res.ok) {
    return new Response(`ZiB encode request failed: ${res.status}`, { status: 502 });
  }
  const job = await res.json();

  // 5. Record new encoding_id. The cdn_url field on your record does
  //    NOT change — the CDN URL is keyed by file_id, not encoding_id.
  await db.content.update({
    where: { id: content_id },
    data: {
      [idKey]: job.encoding_id,
      [statusKey]: 'ENCODING',
    },
  });

  return Response.json({
    encoding_id: job.encoding_id,
    status: job.status,
  });
}
What NOT to do. Don't delete the existing encoded record on your side before firing the re-encode (purge-then-encode). If the encode POST fails after the purge, you've wiped playable content with no replacement. The ZiB-side guarantee is that cdn.mosnet.io/stream/{file_id}/master.m3u8 stays playable across re-encodes — let the cutover happen there.

Quick self-check before shipping

  • Your re-encode endpoint requires auth — anonymous curl against it returns 401.
  • Your re-encode endpoint refuses with 409 if your own DB says encoding is in progress for that content.
  • Your file.ready handler looks up rows by (bucket, object_key), not by file_id. file_id changes on every re-upload; the key does not.
  • Your file.ready handler treats a webhook whose file_id differs from your stored zib_file_id as a re-upload, fires a fresh encoding request, and writes the new file_id back to your row. Idempotency is keyed on file_id, never on encoding_status.
  • Test this end-to-end: upload v1 → encoded → re-upload v2 to the same key → your row points at v2's file_id, v2's encoding_id, v2's CDN URL, and the user can play v2 — without you touching anything by hand.
  • You never delete the prior encoded output on your side before requesting a new encode.
  • You handle { status: 'already_queued' } and { status: 'already_encoded' } the same as a fresh queue: record the encoding_id and move on.

Why Pattern A is so emphatic about file_id comparison

2026-05-13 production deadlock: a tenant's zib-creative-ready handler keyed idempotency on encoding_status !== 'ERROR'. The first upload of a creative encoded fine and the row settled at encoding_status = FINISHED. The user then re-uploaded the same creative via the dashboard. ZiB cancelled the prior encoding row, accepted the new file, and fired file.ready with the new file_id.

The handler ACK'd 200, saw encoding_status === 'FINISHED', decided "already handled", and returned. No /v1/api/encoding/requestwas ever fired for the new file_id. The dashboard UI optimistically set its own local status to Queued — waiting for an encoder. ZiB had no encoding job for the new file and no signal to start one. Both sides waited on the other indefinitely; the user's screen stayed at "Queued" with no error.

Fix shape: idempotency check compares creative.zib_file_id to event.file_id; mismatch means re-upload; re-upload means fire encoding for the new file_id and persist it. Status fields are derived state and unsafe as idempotency keys whenever the entity they describe can be replaced.

Viewer Telemetry

Opt-in browser feature for video tenants. The SDK observes HLS segment fetches via the PerformanceObserver API, batches per-segment timings (TTFB, total duration, transfer size), and posts them to ZiB. The backend fuses them with synthetic and peer-node probes into a per-(node, viewer-region) fitness score that quarantines slow nodes from the segment-holder picker — so a CF colo that's silently routing slowly to one part of the world stops being handed to viewers there.

Optional but recommended for any tenant streaming video. Without RUM, ZiB can only see the network from its own backend vantage — that misses the entire class of geo-specific edge degradation that motivated this system. One line of integration, no UI surface, no PII collected.

What gets sent

  • node_id — the ZB-XXXXXX ID parsed from each segment URL hostname (zb-*.zibnetwork.com)
  • ttfb_ms, total_ms, size_bytes from the browser's resource timing entry
  • An ephemeral random viewer_session_id (UUID) for throttling — never tied to a wallet, account, or persistent identifier
  • The viewer's CF colo (e.g. SYD, LHR) inferred server-side from CF-Ray

No URLs, no IP, no user agent, no cookies. Data is pruned after 24 hours and never leaves ZiB's own infrastructure.

SDK 1.4.0 — three observation channels. The SDK uses whichever channels your stack makes available: PerformanceObserver (passive, free, catches main-thread fetches), recordFetch() (explicit per-segment hook for players that fetch in a Web Worker — bitmovin, modern hls.js, shaka, dash.js — where the observer is structurally blind), and attachToVideo() (universal element-level fallback that works even on Safari native HLS). For commercial players, recordFetch() is the primary integration.

Quickest integration — one line

If your player is a vanilla <video src=".m3u8"> or simple hls.js setup with workers disabled, just call the static helper once on page load. Telemetry observes Resource Timing in the background and flushes on pagehide.

html
<script src="https://app.zibnetwork.com/sdk/zib-sdk.js"></script>
<script>
  // Anywhere on a watch page, after the SDK script loads.
  // Idempotent — calling twice is harmless.
  ZiBStorage.startTelemetry();
</script>
Most modern players need an explicit hook. Bitmovin, hls.js with enableWorker: true (the default in recent versions), shaka, and dash.js all fetch HLS segments inside a Web Worker. Worker fetches do not appear in main-thread PerformanceObserver, so the quick-start above produces zero data with these players. Add ZiBStorage.recordFetch() on your player's segment-loaded event (next subsection) — it's a 5-line addition.

recordFetch() — the explicit per-segment API

Hook your player's per-segment download event and pass the URL + timing to ZiBStorage.recordFetch(). The SDK parses the node id from the URL, batches the sample, and ships it through the same flush pipeline. No prior startTelemetry() call required — the first recordFetch() lazily initialises the singleton.

javascript
// === bitmovin player (8.x — verified against 8.241.0) ===
// IMPORTANT: use SegmentRequestFinished, not SegmentPlayback.
//   - SegmentPlayback fires when a segment starts playing (no timing data)
//   - SegmentRequestFinished fires after download with downloadTime,
//     timeToFirstByte, size, success, httpStatus, url
//
// SegmentRequestFailed does NOT exist in 8.x — failures arrive on
// Finished with event.success === false. httpStatus === 0 means network
// error / CORS reject / abort.
//
// Time units: bitmovin's TypeScript type doc says seconds for downloadTime
// and timeToFirstByte. Multiply by 1000 to get ms. Verify against the DB
// once samples flow — if avg ttfb_ms ends up in the 50,000+ range, the
// runtime actually emitted ms and the multiplier should be removed.
player.on(bitmovin.player.PlayerEvent.SegmentRequestFinished, (event) => {
  // Clamp the -1 "cancelled before headers" sentinel.
  const ttfbSec = Math.max(0, event.timeToFirstByte || 0);
  ZiBStorage.recordFetch({
    url: event.url,
    ttfb_ms: Math.round(ttfbSec * 1000),
    total_ms: Math.round((event.downloadTime || 0) * 1000),
    size_bytes: event.size || 0,
    status: event.success === false
      ? (event.httpStatus === 0 ? 'network_error' : String(event.httpStatus))
      : (event.httpStatus ? String(event.httpStatus) : '200'),
  });
});

// === hls.js (any version) ===
hls.on(Hls.Events.FRAG_LOADED, (_, data) => {
  const stats = data.frag.stats || data.stats;  // hls.js v1 vs v0 paths
  ZiBStorage.recordFetch({
    url: data.frag.url,
    ttfb_ms: Math.round(stats.tfirst - stats.trequest),
    total_ms: Math.round(stats.tload - stats.trequest),
    size_bytes: stats.total,
    status: '200',
  });
});

// === shaka player ===
player.addEventListener('downloadcompleted', (event) => {
  ZiBStorage.recordFetch({
    url: event.request.uris[0],
    ttfb_ms: Math.round(event.response.headers['x-firstbyte-ms'] || event.response.timeMs),
    total_ms: Math.round(event.response.timeMs),
    size_bytes: event.response.data.byteLength,
    status: '200',
  });
});

Samples whose URL doesn't match zb-XXXXXX.zibnetwork.com are silently dropped — without a node identifier in the URL there is nothing to attribute. Pass node_id directly if you have it from another source. Errors are reported with status: 'timeout' or 'network_error'.

attachToVideo() — universal element-level fallback

For coverage that works regardless of player or fetching architecture — including Safari native HLS where neither PerformanceObserver nor a Worker hook is available — attach to the <video> element directly. The SDK reports stalls (waiting → playing transitions), frame drops (via getVideoPlaybackQuality()), and playback errors. File-correlated, not per-segment; the backend resolves to nodes via segment_holders at sample time.

javascript
// On player init, after the <video> element is in the DOM
const detach = ZiBStorage.attachToVideo(videoEl, { fileId: '<file-uuid>' });

// On player teardown
detach();
Wire both channels — they're not interchangeable. recordFetch() gives ZiB per-segment-per-node TTFB attribution (the signal Phase 2 routing acts on). attachToVideo() reports file-correlated stalls and frame drops (file-level signal that backstops Safari iOS whererecordFetch can't fire). Shipping only one leaves a structural gap:attachToVideo alone has no node attribution; recordFetch alone misses Safari iOS viewers and any playback issue that isn't a segment-fetch problem (audio glitches, decoder stalls, etc.). Both share the same singleton and flush in the same batch — total cost is two function calls on player init.

Transport & CORS — what the SDK sends, what the API accepts

The SDK posts samples to POST https://api.zibnetwork.com/v1/api/telemetry/segment-fetch every 30 seconds or every 10 samples, whichever comes first, plus one final pagehide flush via navigator.sendBeacon(). The endpoint accepts both transports — the responses are equivalent. You don't need to configure anything for this to work; the notes below exist for tenants who are debugging from DevTools and want to know what to expect.

CORS shape

  • Access-Control-Allow-Origin echoes the request Origin (not *) so that navigator.sendBeacon() works — sendBeacon is credentialed by spec, and browsers reject wildcard origins on credentialed responses.
  • Access-Control-Allow-Credentials: true is set for the same reason. The endpoint itself is anonymous (no cookie or auth read); the header exists purely so the browser doesn't drop the response.
  • Vary: Origin is set on every response — CDNs caching this endpoint should respect it.
  • If you build a custom telemetry helper (instead of using the SDK) and see "Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true", you're hitting a non-telemetry endpoint by mistake — the global API CORS is wildcard. The telemetry endpoint has the per-route override.

Bring your own session ID

If you already have a playback session ID (e.g. from your analytics), pass it to the static helper. Use the static helper on player pages, never new ZiBStorage({ telemetry }): the constructor accepts a telemetry option, but it also requires your access key and secret key, which must never be on a page a browser loads.

javascript
ZiBStorage.startTelemetry({ viewerSessionId: '...' });

React / Next.js — mount in the root layout

Mount it once at the root layout, not on the watch page. Tenants frequently play video on more surfaces than just /watch/<slug> — content-detail pages with trailers, search-result hover previews, embed routes, series episode views. If telemetry only mounts on a single route, every other player surface goes uninstrumented and the dashboard looks emptier than reality. The SDK is ~30 KB gzipped and startTelemetry() is a no-op on pages without zb-*.zibnetwork.com Resource Timing entries, so the cost on non-player routes is one cached <script> tag and a dormant PerformanceObserver. Mount high, forget about it.

Drop the SDK <Script> and a small client wrapper into your root app/layout.tsx — not a route-group layout like app/(app)/layout.tsx. Route-group layouts only cover routes inside that group; the root layout covers everything.

tsx
// app/layout.tsx — Server Component is fine
import Script from 'next/script';
import { ZiBTelemetry } from '@/components/ZiBTelemetry';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* Loads on every page; next/script dedupes by src so it's safe to
            keep any per-page <Script> tags in upload flows that need a hard
            guarantee the SDK is present. */}
        <Script src="https://app.zibnetwork.com/sdk/zib-sdk.js" strategy="afterInteractive" />
        <ZiBTelemetry />
        {children}
      </body>
    </html>
  );
}
tsx
// components/ZiBTelemetry.tsx — small client wrapper
'use client';
import { useEffect } from 'react';

export function ZiBTelemetry() {
  useEffect(() => {
    let tele: { stop: () => void } | undefined;
    let cancelled = false;

    // Poll for the SDK global — handles the race where <Script
    // strategy="afterInteractive"> hasn't finished loading yet.
    const tick = () => {
      if (cancelled) return;
      // @ts-ignore — global injected by the SDK <script>
      const ZiBStorage = (window as any).ZiBStorage;
      if (ZiBStorage?.startTelemetry) {
        tele = ZiBStorage.startTelemetry();
        return;
      }
      setTimeout(tick, 100);
    };
    tick();

    return () => {
      cancelled = true;
      tele?.stop();   // fires final navigator.sendBeacon flush
    };
  }, []);

  return null;
}

That's the entire integration for any Next.js App Router app. Pages Router is the same idea — drop the <Script> and <ZiBTelemetry /> into pages/_app.tsx instead.

Common placement traps

  • Route-group layout (app/(app)/layout.tsx) — covers only that group. Marketing/landing pages, content-detail pages, public preview surfaces typically live in a different group and would miss telemetry. Mount in the root layout instead.
  • Single watch route (app/watch/[slug]/page.tsx) — covers exactly one route. Trailers, embed players, hover previews on other routes go uninstrumented.
  • Edit-mode-only mount — gating the SDK <Script> behind an isEditMode flag (common in CMS-like rite3 setups) means viewers never load it. Either ungate, or mount in the root layout where the gate doesn't apply.

How to verify it's working

  1. Test on at least two different player surfaces — e.g. a watch page and a content-detail page that plays a trailer. If telemetry only fires on one, your mount is too narrow (route-group layout instead of root layout) — see the placement traps above.
  2. Open DevTools → Network and filter for zib-sdk.js. The script should load on every page where you expect telemetry, including non-player pages that share the same root layout.
  3. Switch the filter to telemetry and play a video for ~30 s. You should see a POST to /v1/api/telemetry/segment-fetch every 30 s or every 10 segments, returning { accepted: <n>, rejected: 0 }. Confirm the segments themselves are coming from zb-*.zibnetwork.com hostnames in the Domain column — that's the URL pattern the SDK matches.
  4. Close the tab. The SDK fires a final navigator.sendBeacon on pagehide; that flush still shows up in DevTools' Network panel under Show navigation requests.
  5. If you have admin access, your samples land in /superadmin/network within ~60 s, tagged with the appropriate CF colo region (SYD, LHR, etc.) rather than the synthetic-probe sentinel _backend.

Diagnose by response shape

The /v1/api/telemetry/segment-fetch response shape tells you exactly which channels are wired and which are missing. Click any telemetry POST in DevTools → Response and read both fields:

ResponseDiagnosisFix
{ accepted: N, ... }
{ playback_events_accepted: M }
both > 0
Both channels wired. Full coverage.Done. ✅
{ accepted: N }
(no playback_events)
recordFetch wired; attachToVideo not called.Add ZiBStorage.attachToVideo(videoEl, { fileId }) on player init. Backstops Safari iOS where worker fetches don't fire.
{ accepted: 0,
playback_events_accepted: M }
(M > 0)
attachToVideo wired; recordFetch not called. Most common gotcha for commercial-player tenants.Add the player's segment-loaded event hook (bitmovin SegmentPlayback, hls.js FRAG_LOADED, etc.) calling ZiBStorage.recordFetch(). Without this you have no per-node TTFB attribution.
{ accepted: 0, rejected: N }
(N > 0)
recordFetch wired but the URL pattern doesn't match zb-XXXXXX.zibnetwork.com.Log event.segment.uri from your hook. If you see cdn.* instead of zb-* hosts, the player is using CDN-cached URLs — pass node_id directly from another source (e.g. holder lookup), or contact ZiB to widen the regex.
No POST at allSDK didn't load, or the hook isn't on the active route, or the player isn't actually playing.Console: typeof ZiBStorage should be 'function'. If 'undefined', the <Script> tag isn't on this route — see "Common placement traps" above.

The third row — accepted: 0, playback_events_accepted: N — is by far the most common partial-integration state. attachToVideo is a 2-line addition while the player-event hook needs the integrator to know their player's event API; tenants tend to ship the easy one first and forget the other. Both are needed for full coverage.

SDK matches direct-to-node URLs only. Modern HLS AES-128 content streams segments direct from zb-XXXXXX.zibnetwork.com/<hash> — that's what the SDK observes. Legacy content that pre-dates HLS AES-128 still streams via the CDN proxy (cdn.zibnetwork.com/stream/<file_id>/segments/...); those fetches don't carry a node identifier in the URL and aren't observed. If all your video is legacy, you'll see telemetry POSTs reporting { accepted: 0 } — re-encode a piece of content via the modern pipeline to verify, or check the Domaincolumn in DevTools to confirm at least some segments are zb-*.zibnetwork.com.
Phase 1 is detection only. Right now telemetry feeds the dashboard only — segment-holder selection in the CDN layer hasn't been changed yet. Phase 2 (next release) wires the fitness score into routing so a degraded node is automatically deprioritised for the affected viewer regions.

Webhooks

ZiB sends signed POST requests to your configured webhook URL when files, encoding jobs, and AI jobs change state.

Configuration patterns

There are three valid ways to consume ZiB lifecycle events. Pick the one that matches how your integration is operated:

Pattern 1 — Per-requestrecommended for engineering teams

Send webhook_url and webhook_secret in the body of every POST /v1/api/encoding/request (or inside the pipeline field of /v1/api/upload/initiate). The URL and secret travel with the request in code — survives any tenant-level config drift, no ops dependency, easy to rotate per environment.

A per-request URL only receives job events — encoding.*, transcription.complete, vision.complete. It never receives file.ready, file.deleted or upload.reassigned; those go only to the account-level URL (Pattern 2). If you need file.ready — for example to start an encode — set the account-level URL as well.

Pattern 2 — Tenant-level defaultfor operator-managed products

Configure once via the Webhooks panel on the Storage page. The panel calls PATCH /v1/api/customers/<customer_id>/webhook + POST /v1/api/customers/<customer_id>/webhook/secret, which authenticate with your signed-in dashboard session, not an access key. In the PATCH body, webhook_url sets the URL for file.ready and encoding_webhook_url sets the URL for file.deleted, upload.reassigned, file.failed (unless a URL was stored with that upload) and the default for job events. An omitted or null field keeps its current value, so the PATCH cannot clear a URL. Simpler when one URL serves the whole tenant; vulnerable to silent breakage if an operator clears the URL or rotates the secret without notifying the receiving service.

Checking what is configured: the account-level URLs can only be read in the dashboard (Storage → Webhooks). There is no access-key API to read them back today, so a server-side health check cannot confirm them. If you need one, tell us. Until then, a cheap check is whether file.ready arrives for a test upload.

Pattern 3 — Polling onlywhen you can't host inbound HTTPS

Skip webhooks entirely; poll GET /v1/api/pipeline/<file_id> until you see a terminal phase. See Video Encoding → Polling encoding status correctly for the correct loop shape. Valid for CLI scripts, background workers, and behind-firewall deployments without ingress; the trade-off is round-trip latency and rate-limit pressure.

Pattern precedence when more than one is configured: per-request body fields always win over tenant-level defaults. Polling can be combined with either of the other two as belt-and-braces.

Common pitfall: if your code assumes tenant-level config is set but the operator clears it (or never set it), encoding requests still succeed but no webhooks fire — silent breakage. Pattern 1 avoids this entire class by carrying config in every request.

Which URL a job event goes to is fixed when the job is created. POST /v1/api/encoding/request stores the per-request webhook_url, or, if you sent none, your account's job-event default (encoding_webhook_url). An encode declared in an upload pipeline uses the pipeline's webhook_url. The account default is not applied there, so put the URL in the pipeline. Jobs created with POST /v1/api/encoding/jobs (the SDK's startEncoding) store no URL and send no encoding.* webhooks at all: poll them, or use /encoding/request. Changing the account default later does not re-route jobs that already exist.

Headers

Every webhook request carries:

text
Content-Type:    application/json
X-ZiB-Event:     <event name>            e.g. encoding.complete
X-ZiB-Job-ID:    <uuid>                  on encoding.* and {transcription,vision}.complete
X-ZiB-Signature: sha256=<hex>            HMAC-SHA256 over the raw request body

Events

file.readyFile is fully sharded across the network. From here on the file_id is permanent.
file.replicatedNOT CURRENTLY EMITTED. This event is listed here because integrations were built against it; nothing in the backend fires it, so a workflow that waits for it waits forever. Do not gate backups or any other step on it. For video, encoding.complete plus phase "safe" on GET /v1/api/pipeline/{file_id} is the durability signal that does fire. If you need a replication webhook for non-video objects, tell us — the state is tracked, only the notification is missing.
file.failedFile became terminal-cancelled (currently fires on re-upload supersede; cancelled_reason in payload distinguishes scenarios). Tenants relying on this file_id should switch to the new one.
encoding.queuedEncoding job has been accepted and is waiting for a node.
encoding.startedA node has claimed the job and begun encoding.
encoding.progressNOT EMITTED. Listed because integrations were built against it. Nothing fires this event — the encoder writes progress to the database, but no webhook is derived from it. Poll GET /v1/api/pipeline/{file_id} for a 0–100 percentage instead.
encoding.shardingTranscoding finished; encrypted segments are being distributed.
encoding.completeHLS manifest is live on the CDN — the file is playable now. Fires as soon as the originating node finishes sharding; ZiB replicates to peers in the background and the CDN seamlessly hands off as that completes (no tenant action needed).
encoding.failedEncoding failed — check error_message in the payload.
transcription.completeWebVTT subtitle track is ready (srt_file_id in extras).
vision.completeZibSidecar JSON is ready (sidecar_file_id in extras).

Payload shapes

Each event has its own envelope. All payloads include a top-level event string and timestamp (ISO 8601 UTC).

json
// file.ready — fired the moment shard count reaches the redundancy target
{
  "event":       "file.ready",
  "file_id":     "a1b2c3d4-...",
  "bucket":      "my-bucket",
  "object_key":  "videos/2026-04/film.mp4",
  "size_bytes":  524288000,
  "customer_id": "c0ffee...",
  "timestamp":   "2026-04-08T17:30:00.000Z"
}
json
// encoding.queued | encoding.started | encoding.sharding | encoding.complete | encoding.failed
// All five share the same shape — only the "event" and "status" fields change.
{
  "event":               "encoding.complete",
  "job_id":              "<encoding_jobs.id>",
  "status":              "complete",          // queued | encoding | sharding | complete | failed
  "file_id":             "a1b2c3d4-...",
  "customer_id":         "c0ffee...",
  "hls_manifest_url":    "https://cdn.zibnetwork.com/stream/<file_id>/master.m3u8",  // null until "complete"; set by the encoder, either host plays
  "error_message":       null,                // populated on "failed"
  "transcription_model": "recommended",       // null if transcription not requested
  "vision_model":        "standard",          // null if vision not requested
  "thumbnails": {                              // null if tenant opted out; see "Thumbnails" above
    "poster_url":     "https://cdn.mosnet.io/objects/<poster_file_id>?exp=...&tok=...",
    "sprite_url":     "https://cdn.mosnet.io/objects/<first_sprite_sheet_file_id>?exp=...&tok=...",
    "sprite_vtt_url": "https://cdn.mosnet.io/objects/<sprite_vtt_file_id>?exp=...&tok=..."
  },
  "aspect_variants": {                         // omitted if tenant didn't opt in; see "Aspect variants" above
    "9:16": {
      "hls_manifest_url": "https://cdn.mosnet.io/stream/<vertical_file_id>/master.m3u8",
      "poster_url":       "https://cdn.mosnet.io/objects/<vertical_poster_id>?exp=...&tok=..."
    }
  },
  "timestamp":           "2026-04-08T17:32:14.000Z"
}
json
// encoding.progress — NOT EMITTED. Kept here only so that anyone who built
// against this shape can recognise it. No such webhook is delivered; poll
// GET /v1/api/pipeline/{file_id} and read the encoding stage's progress.
//
// You don't need this event to know the job will eventually finish — the
// existing encoding.complete event still fires. encoding.progress is purely
// for UI feedback.
{
  "event":     "encoding.progress",
  "job_id":    "<encoding_jobs.id>",
  "file_id":   "a1b2c3d4-...",
  "progress":  47,                 // integer 0–100
  "stage":     "encoding",         // "encoding" | "sharding"
  "timestamp": "2026-04-08T17:31:08.000Z"
}
json
// transcription.complete — fires when the SRT/WebVTT artefact has been
// produced and stored. Fetch via https://cdn.mosnet.io/objects/<srt_file_id>
{
  "event":        "transcription.complete",
  "job_id":       "<encoding_jobs.id OR compute_jobs.id>",
  "file_id":      "a1b2c3d4-...",
  "srt_file_id":  "f00d...",
  "timestamp":    "2026-04-08T17:34:02.000Z"
}
Fetching a sidecar: resolve sidecar_file_id through /v1/api/object-url and fetch the URL it returns. /v1/api/files/{id}/download will never work for a sidecar — that route reads the legacy files table, and compute artifacts (sidecars, subtitle tracks, sprites) are only ever written to s3_objects. A tenant hit this and reasonably assumed the two paths were interchangeable; they are not.
json
// vision.complete — fires when the ZibSidecar JSON has been produced and
// stored. Resolve sidecar_file_id via /v1/api/object-url, then fetch that URL
// and parse as JSON for the full sidecar (scenes, IAB categories, etc.).
{
  "event":            "vision.complete",
  "job_id":           "<encoding_jobs.id OR compute_jobs.id>",
  "file_id":          "a1b2c3d4-...",
  "sidecar_file_id":  "ba11...",
  "timestamp":        "2026-04-08T17:36:48.000Z"
}
job_id semantics. The job_id field carries an encoding_jobs.id for encoding.* events. For transcription.complete and vision.complete it carries either a compute_jobs.id (when the AI was submitted as a standalone compute call) OR an encoding_jobs.id (when the AI ran as part of a combined encode+AI request). If you need a single dispatcher across both pipelines, route by file_id instead — it is unique per file regardless of which pipeline produced the event.
Re-uploads supersede previous uploads. When you upload a new file to a (bucket, object_key) that already has an object, ZiB replaces the prior file_id with a fresh one, marks the old row cancelled, and evicts the prior bytes from every node that held them (source shards + encoded segments). This applies to every upload type — images, JSON, generic blobs, and encoded video alike — not just files that have an encoding job attached. If the prior upload had an in-flight encoding job, that job is cancelled and no further encoding.* webhooks fire for it. You will only ever receive lifecycle events for the most recent upload to a given key. This means:
  • Route lifecycle events by file_id — it is the canonical discriminator and is guaranteed unique per upload.
  • When you receive file.ready for a key you've seen before, treat it as a hard reset: clear any state stored against the previous file_id for that key, including any prior job_id, CDN URLs, manifests, and progress.
  • Do not maintain a fallback that matches webhooks by job_id alone — a stale job_id from a prior upload should not be allowed to overwrite state for the current one.
  • The CDN URL cdn.mosnet.io/objects/{file_id} always reflects the latest upload, because the new file_id is what the SDK returns and what S3 GET redirects to.
  • You do not need to call DELETE /v1/api/file/{prior_file_id} as a cleanup step on re-upload — the bytes are reclaimed automatically. The cancelled s3_objects row stays for audit and is harvested by orphan cleanup later. Explicit DELETE is still the right call when you want to remove a file without replacing it.

Verifying signatures

Every webhook request includes an X-ZiB-Signature header containing an HMAC-SHA256 signature of the raw request body. Always verify this before processing the payload.

javascript
import crypto from 'crypto';

export async function POST(req) {
  // Read raw body before JSON parsing — signature is over raw bytes
  const body = await req.text();
  const sig = req.headers.get('x-zib-signature'); // "sha256=<hex>"

  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', process.env.ZIB_WEBHOOK_SECRET)
      .update(body)
      .digest('hex');

  if (sig !== expected) {
    return new Response('Unauthorized', { status: 401 });
  }

  const payload = JSON.parse(body);

  if (payload.event === 'file.ready') {
    // update your DB with cdn_url, mark upload complete, etc.
  }

  if (payload.event === 'encoding.complete') {
    // store hls_manifest_url, notify your users, etc.
  }

  return new Response('OK');
}
Use a constant-time comparison (like crypto.timingSafeEqual) to avoid timing attacks when comparing HMAC signatures.

Rotating the webhook secret

Rotating the secret (dashboard → Webhooks) keeps the previous secret valid for 24 hours. During that window, account-level file events (file.ready, file.deleted, file.failed, upload.reassigned) are signed twice: X-ZiB-Signature with the new secret and X-ZiB-Signature-Previous with the old one. Accept either while you deploy the new secret:

javascript
import crypto from 'crypto';

const sign = (secret, body) =>
  'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
const same = (a, b) =>
  !!a && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// During the 24 h rotation window, keep BOTH secrets configured on your side.
function verifyZibSignature(req, body) {
  const secrets = [process.env.ZIB_WEBHOOK_SECRET, process.env.ZIB_WEBHOOK_SECRET_OLD].filter(Boolean);
  const headers = [req.headers.get('x-zib-signature'), req.headers.get('x-zib-signature-previous')];
  return secrets.some((s) => headers.some((h) => same(h, sign(s, body))));
}
Job events are signed once, with the secret that was current when the job was requested. encoding.*, transcription.complete and vision.complete carry only X-ZiB-Signature. Jobs that started before a rotation keep signing with the old secret for as long as they run, which can be past the 24 hours. A job that passed its own webhook_secret signs with that. So keep the old secret in your verifier (as above) until your in-flight jobs have finished, not just for 24 hours.

Deleting Files

When you remove an asset from your application — a creator deletes a piece of content, a poster gets replaced, a blog post comes down — call the delete endpoint to remove the underlying bytes from ZiB. A single call cleans up everything: the encrypted chunks on every node holding them, any encoded HLS segments, the database row, the bucket counters, and the Cloudflare CDN cache.

Auth: server-side Authorization: Bearer access_key:secret_key — the same credentials you use for /v1/api/upload/initiate. Never call this from a browser; the secret must not leave your server.

Two ways to delete

Pick whichever you have on hand. Most tenants store the file_id returned by uploadDirect() alongside their content row, so the by-file_id form is usually the cheaper path.

bash
# Form A — by file_id (preferred)
curl -X DELETE \
  -H "Authorization: Bearer $ZIB_ACCESS_KEY:$ZIB_SECRET_KEY" \
  https://api.mosnet.io/v1/api/file/01846192-315c-4f0a-b766-12b18a26b209

# Form B — by bucket + object_key (S3-shaped)
curl -X DELETE \
  -H "Authorization: Bearer $ZIB_ACCESS_KEY:$ZIB_SECRET_KEY" \
  https://api.mosnet.io/v1/api/storage/posters/abc123/poster.card.webp

Response shape

json
{
  "success": true,
  "file_id": "01846192-315c-4f0a-b766-12b18a26b209",
  "bucket": "posters",
  "key": "abc123/poster.card.webp",
  "deleted": {
    "video_segments": 0,
    "encoding_jobs": 0,
    "shards": 12,
    "s3_objects": 1,
    "files": 1
  },
  "node_cleanup": {
    "ok": 12,
    "failed": 0,
    "queued_for_offline": 4
  },
  "encoding_jobs_cancelled": 0
}

The deleted map shows row counts removed from each table — shards is a legacy field name that now counts chunk-holder records, not erasure-coded shards. The node_cleanup map distinguishes nodes that confirmed the delete synchronously (ok) from those that didn't respond in time (failed) — the latter still get a durable queued delete command that the node claims when it next comes back, so eventual cleanup of a file's chunks does not depend on the node being reachable at the moment you call.

Idempotency

Deleting a file_id that doesn't exist (or that belongs to another customer) returns 200 with { success: true, already_deleted: true } rather than 404. This deliberately doesn't leak ownership info, and it makes "clean up everything in this list" loops trivially safe to retry.

json
// Repeat call after the file is gone
{
  "success": true,
  "already_deleted": true,
  "file_id": "01846192-315c-4f0a-b766-12b18a26b209"
}

Side effects

  • In-flight encoding cancelled. If you delete a video while it's still being encoded, the encoding job is marked cancelled before the bytes go away. The encoder won't deliver any further encoding.* webhooks for that file_id.
  • CDN cache purged. ZiB calls Cloudflare's purge API for /objects/{file_id} and /stream/{file_id}/ on both CDN hosts (cdn.mosnet.io and cdn.zibnetwork.com). Worldwide eviction typically completes within ~30 seconds. Until then, edges with a warm cache may continue to serve the asset.
  • Source bytes removed and disk reclaimed. Every node holding an encrypted chunk of this file is told to drop it. Holders then reconcile against the live object set, untag the now-orphaned chunks, and garbage-collect them — so the disk is genuinely returned to the network, not merely dereferenced. Reclamation is asynchronous and typically completes within a GC cycle of the delete.
  • Bucket + storage counters decremented. Reflected immediately in /v1/api/me/buckets and your storage usage dashboard.
Hard delete, no undo. There is no soft-delete or restore path. Once a delete returns success, the bytes are unrecoverable. Confirm at the application layer before you call.

The file.deleted webhook

If you have an account-level webhook URL configured, ZiB fires file.deleted after a successful delete. The shape mirrors file.ready so the same handler can route by event name. Headers: X-ZiB-Event: file.deleted, X-ZiB-File-ID, and the standard X-ZiB-Signature: sha256={hex}.

These events go to your account-level webhook ONLY. file.deleted, file.ready, and upload.reassigned are delivered to the URL configured on your account (customers.default_webhook_url / file_ready_webhook_url) — a per-request webhook_url (passed on /encoding/request or nested in /upload/initiate's pipeline) does not receive them. Per-request webhooks only carry the job-lifecycle events (encoding.*, transcription.complete, vision.complete). If you use the per-request pattern exclusively, set an account-level webhook too, or you will silently miss file.ready / file.deleted.
json
{
  "event": "file.deleted",
  "file_id": "01846192-315c-4f0a-b766-12b18a26b209",
  "bucket": "posters",
  "key": "abc123/poster.card.webp",
  "timestamp": "2026-05-14T03:21:09.482Z"
}

The webhook is fire-and-forget — the API response returns as soon as the bytes are gone, and webhook delivery happens asynchronously with the same 3-attempt retry + exponential backoff as every other ZiB webhook. Don't treat it as your trigger to remove the row from your DB — the API response already told you the delete succeeded. The webhook is for fan-out (audit logs, search index invalidation, etc.) when multiple systems want to know.

Recommended patterns

Pattern 1 — content row delete cascades to ZiB. When a creator removes a piece of content, fan out to ZiB for every asset stored on the row.

typescript
// app/api/content/[id]/delete/route.ts
import { NextResponse } from 'next/server';

export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  const content = await db.content.findUnique({ where: { id: params.id } });
  if (!content) return NextResponse.json({ ok: true }, { status: 200 });

  // 1. Collect every ZiB file_id this row references.
  const fileIds = [
    content.video_file_id,
    content.poster_thumb_id,
    content.poster_card_id,
    content.poster_hero_id,
  ].filter(Boolean);

  // 2. Delete from ZiB in parallel — the endpoint is idempotent so retries are safe.
  await Promise.all(
    fileIds.map((id) =>
      fetch(`https://api.mosnet.io/v1/api/file/${id}`, {
        method: 'DELETE',
        headers: { Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}` },
      }).catch((e) => console.warn(`ZiB delete failed for ${id}`, e))
    )
  );

  // 3. Delete the local row last — if step 2 partially fails, your DB still
  //    holds the file_ids so a cleanup job can retry.
  await db.content.delete({ where: { id: params.id } });
  return NextResponse.json({ ok: true });
}

Pattern 2 — replace an asset. Uploading a new file to the same (bucket, object_key) as an existing asset automatically evicts the prior bytes from every node and decrements your storage counters — no explicit DELETE call is needed. Just upload the new asset and write the new file_id to your local row.

typescript
async function replacePoster(contentId: string, newFile: File) {
  // 1. Upload the new poster to the same object_key. ZiB detects the
  //    existing row at (bucket, object_key), cancels any in-flight
  //    encoding on the prior file_id, fires file.failed for it, and
  //    evicts the prior bytes from every holding node in the background.
  const init = await fetch('/api/posters/upload-initiate', { method: 'POST', body: JSON.stringify({ contentId }) });
  const registration = await init.json();
  const { file_id: newFileId } = await ZiBStorage.uploadDirect(newFile, registration);

  // 2. Write the new file_id to your row. The supersede flow has already
  //    taken care of evicting the prior bytes — no follow-up DELETE needed.
  await db.content.update({ where: { id: contentId }, data: { poster_card_id: newFileId } });
}

Two corollaries:

  • If you receive file.failed with cancelled_reason: "superseded by re-upload to same bucket+object_key", it's informational only — you don't need to take any action; the eviction is already underway.
  • Explicit DELETE /v1/api/file/{file_id} is still the right call when you want to remove a file without replacing it (Pattern 1 above) — that path additionally drops the audit row + fires file.deleted.
GDPR / right-to-be-forgotten. A successful delete response is your audit trail that the bytes have been removed from the storage network. Combine with the file.deleted webhook receipt log if your compliance posture requires a second-system attestation.

Security Model

ZiB guarantees
  • Every file encrypted with a unique key before leaving the browser
  • Keys stored only in ZiB backend — never on storage nodes
  • Node operators see only encrypted fragments — no filenames, no keys, no plaintext
  • Node-facing identifiers are opaque UUIDs — real object keys never exposed
  • High redundancy — files survive multiple simultaneous node failures
Your responsibility
  • Keep access_key and secret_key on your server only
  • Authenticate your own users before calling /v1/api/upload/initiate
  • Store file_id — not the object URL — and resolve a URL when you render (Object URLs)
  • Verify webhook signatures with X-ZiB-Signature before processing
  • Access control for object URLs — a signed URL is a bearer token anyone holding it can use until it expires, so decide who gets one

Why credentials must stay on the server

The upload/initiate endpoint uses your master credentials, which have full access to your ZiB account — all buckets, all objects, all quota. Exposing these in browser JavaScript would let anyone who reads your source code upload to your buckets and exhaust your storage quota.

The registration object returned by initiate authorises a single upload to a single pre-determined location. It cannot be used to list buckets, delete objects, or create a second object, and the backend refuses a second, different set of bytes for the same id. That bound is what makes it safe to pass from your server to the browser — not an absence of secrets, which is the part integrators most often get wrong.

The registration is not presigned, and it does carry a secret. The upload URL is an unguessable UUID path — there is no signature on it, and the storage node accepts a PUT to that path without checking one. It does expire: seven days after initiate, both the part and complete endpoints return 410. The registration also contains the file's encryption_key and encryption_iv in plaintext, because the browser is what performs the encryption. Handle it accordingly: send it over TLS to the browser doing that one upload, keep it out of logs and error reports, and do not persist it after the upload completes. Anyone holding it can write that object's bytes and read them back, for as long as it is unexpired — and nothing else.

Self-enforcing encryption

ZiB's encryption model is self-enforcing. If an attacker obtained a registration token and uploaded plaintext bytes directly to the node URL (bypassing the SDK), the CDN would decrypt those bytes using the key generated at initiate time and return garbage — because the bytes stored on the node would not be valid ciphertext for that key.

There is no configuration flag, no "plaintext mode", and no bypass. The CDN always decrypts. Files that were not encrypted correctly before upload cannot be served correctly — ever. This makes the security property durable even if an integration makes a mistake.

Every bucket on the platform is private, and none can be made public. No API sets buckets.public_read — the endpoint that once claimed to (PATCH /v1/api/buckets/{id}/policy) has never existed — and we do not flip buckets on request. Objects are encrypted at rest with a per-object AES-256-GCM key, and storage nodes only ever hold ciphertext. None of that is what keeps an object private, and we would rather you heard it from us than assumed otherwise. Object reads have never consulted the bucket flag, so a private bucket does not make its objects private any more than a public one would have made them public. Encryption defends the storage layer — it stops a node operator reading what they store — and the decryption key is handed to whoever is allowed to read the object, without account credentials, because that is how a browser decrypts locally. What actually governs who can read an object is the next callout.
Treat the file_id — and any signed URL — as a secret. Object reads (/objects/<uuid> and the SDK's direct-read coordinates, /objects/<uuid>/serve-coords) carry no account credential by design, so that a browser can pull from a holder and decrypt locally. They are gated by the signed exp/tok token. Whether a request without a token is refused is a server-side switch, set globally or per account. Until it is refused for your account, assume anyone who learns a file_id can read that object, source masters included. Even once it is refused, a signed URL lets anyone holding it read the object until it expires, so it narrows the exposure without closing it. Do not put file_ids or signed URLs in public listings, in client-side bundles for content meant to be private, or anywhere you would not put a download link. If you need content that stays private from people who might obtain the id, say so: that needs an access-control model ZiB does not have today, not a URL change.

Request logging. API requests are logged with the client IP address, the method, the route, the status and the latency. The route is logged as its template (for example /v1/api/upload/:upload_id/complete; for an unknown route, id-like segments are masked), so ids in the path are not recorded, and the query string (where signed-URL tokens travel) is never logged.

Build only on signed object URLs. Store the file_id, not the URL, and resolve one at render time via Object URLs. A hand-built or stored unsigned cdn.mosnet.io/objects/<uuid> URL can start returning 403 without a code change on your side. That section has the test recipe and explains how to opt your account in to refusing unsigned reads. Streaming URLs (/stream/{file_id}/master.m3u8) are generated per request and remain safe to store.

Job Status API

Webhooks are the fastest way to know a file finished encoding, but if you need to render progress to an end user (an upload dashboard, a "your video is processing" screen, a retry button), poll GET /v1/api/my-jobs. Every encoding job in your account is returned with its current lifecycle phase — from queued all the way through safe (replicated to enough peers that the file is durable).

Auth: same as every other tenant endpoint — Authorization: Bearer access_key:secret_key. Jobs returned are scoped to your customer automatically.

Lifecycle phases

Each job is in exactly one phase:

  • queued — accepted, waiting for a node to claim it
  • encoding — FFmpeg is running; phase_progress_pct ticks 0–100
  • sharding — encoded segments are being hashed + written to storage
  • local_complete — all bytes on the originating node, awaiting backend greenlight
  • replicating — segments are being pulled to peer nodes
  • safe — ✅ 3+ peers confirmed; the file is durably stored and will survive any single-node failure. replication_safe_at is set.
  • failed — transient failure, auto-retrying (retry_after tells when)
  • failed_user_action — retries exhausted; surface a Retry button to the user
  • cancelled — user-cancelled; derivatives being cleaned up

List jobs

bash
curl -s https://api.mosnet.io/v1/api/my-jobs \
  -H "Authorization: Bearer ${ACCESS_KEY}:${SECRET_KEY}"

# Filter:
curl -s "https://api.mosnet.io/v1/api/my-jobs?status=all" \
  -H "Authorization: Bearer ${ACCESS_KEY}:${SECRET_KEY}"

?status=active (default) returns jobs whose phase is not safe or cancelled. ?status=all returns everything.

Response

json
{
  "jobs": [
    {
      "file_id": "0c8e3765-9b2b-4a29-82cb-2f1911fe750c",
      "title": "trailer-final.mp4",
      "phase": "replicating",
      "phase_progress_pct": 66.7,
      "phase_detail": "peer 2 of 3 verified",
      "phase_updated_at": "2026-04-24T05:12:04Z",
      "replication_peer_count": 2,
      "replication_target_count": 3,
      "replication_safe_at": null,
      "retry_count": 0,
      "last_error": null,
      "cancellable": true,
      "retriable": false,
      "created_at": "2026-04-24T04:58:12Z",
      "node_id": "ZB-899233"
    }
  ]
}

Cancel a job

bash
curl -X DELETE https://api.mosnet.io/v1/api/my-jobs/${FILE_ID} \
  -H "Authorization: Bearer ${ACCESS_KEY}:${SECRET_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"reason":"user_abandoned_upload"}'

Sets phase=cancelled and queues cleanup commands to every node that holds any segment of the file. Not reversible. Only valid while cancellable: true.

Retry a failed job

bash
curl -X POST https://api.mosnet.io/v1/api/my-jobs/${FILE_ID}/retry \
  -H "Authorization: Bearer ${ACCESS_KEY}:${SECRET_KEY}"

Resets a failed_user_action job back to queued. Use this behind a Retry button in your UI. Only valid while retriable: true.

Suggested polling cadence

  • While your UI is visible and you have any job in a non-terminal phase: every 5 s
  • Background tab or no active jobs: every 30 s
  • Webhooks remain the preferred signal for terminal transitions — use /my-jobs for in-flight progress UX

The durability guarantee

Once a job reaches phase: "safe", the file has ≥3 verified non-originator peers holding every segment. From that point:

  • Any one peer can go offline permanently and the file is still served
  • The originating node can freely reclaim its local copy
  • Playback is served through cdn.mosnet.io with automatic failover between peers

Before safe is reached, the originator's bytes are unconditionally protected — the file cannot be lost to a transient failure on the originating node while replication is in progress.

Compute / AI

ZiB Compute is an optional AI inference layer built on top of ZiB storage. Compute-capable nodes run transcription and vision analysis on videos stored in the network. Output is an encrypted WebVTT subtitle file (auto-injected into the HLS manifest) and an encrypted ZibSidecar JSON — a full metadata envelope covering transcription, chapters, scene understanding, IAB content taxonomy, GARM brand safety, clip suggestions, and thumbnail candidates.

Compute jobs run on the same encrypted infrastructure as storage. The node decrypts the source video in memory to run inference, then re-encrypts and shards the output before reporting file IDs to the backend. Node operators never see plaintext video or AI output.

Trigger AI from an encoding request

Pass transcription and/or vision options to startEncoding() — the encoding node runs AI immediately after encoding while the decrypted source is already in memory.

javascript
// SERVER-SIDE: `zib` is new ZiBStorage({ accessKey, secretKey }) on your server.
const { encoding_id } = await zib.startEncoding(fileId, {
  transcription: 'recommended',  // 'fastest' | 'recommended' | 'accurate'
  vision: 'standard'             // 'standard' | 'hq'
});

// The AI outputs are NOT on getEncodingStatus(). Read them from the pipeline rollup…
const p = await fetch(`https://api.mosnet.io/v1/api/pipeline/${fileId}`, {
  headers: { Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}` },
}).then((r) => r.json());
// p.stages.transcription.output_file_id → WebVTT file (also auto-injected into the HLS manifest)
// p.stages.vision.output_file_id        → ZibSidecar JSON
// …or from the transcription.complete (srt_file_id) / vision.complete (sidecar_file_id) webhooks.

Standalone compute jobs

Run transcription or vision on any already-stored video you own, with no re-encode. From your server, with your access key:

javascript
const ZIB_AUTH = `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}`;

// Submit — at least one of transcription / vision is required
const job = await fetch(`https://api.mosnet.io/v1/api/compute-api/${fileId}`, {
  method: 'POST',
  headers: { Authorization: ZIB_AUTH, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    transcription: 'balanced',   // 'fast' | 'balanced' | 'accurate'  (aliases: 'fastest', 'recommended')
    vision: 'standard',          // 'standard' | 'hq' — exact names only
    webhook_url: 'https://yourapp.com/zib/compute',   // optional — see below
    webhook_secret: process.env.ZIB_COMPUTE_WEBHOOK_SECRET, // optional — unsigned without it
  }),
}).then((r) => r.json());
// → { job_id, status: 'queued', progress: 0, transcription_model, vision_model,
//     srt_file_id: null, sidecar_file_id: null, error_message: null }

// Poll
const status = await fetch(`https://api.mosnet.io/v1/api/compute-api/job/${job.job_id}`, {
  headers: { Authorization: ZIB_AUTH },
}).then((r) => r.json());
// status.status          → 'queued' | 'processing' | 'complete' | 'failed'
// status.srt_file_id     → WebVTT file id (transcription, when complete)
// status.sidecar_file_id → ZibSidecar file id (vision, when complete)
// status.error_message   → set when failed
  • Errors: 400 if neither transcription nor vision is sent, or the id is not a UUID; 404 if the file is not yours.
  • Tier names are not validated when you submit. For transcription, fast / balanced / accurate and the aliases fastest / recommended are recognised; anything else runs as balanced. For vision, a job is only picked up by a node that advertises that exact name. Use standard or hq, because any other value can stay queued indefinitely.
  • Webhooks: a standalone job sends transcription.complete (with srt_file_id) and/or vision.complete (with sidecar_file_id), each with event, job_id, file_id, timestamp and the X-ZiB-Event / X-ZiB-Job-ID headers. They go only to the webhook_url in this request: there is no account-default fallback. They are signed only if you sent a webhook_secret. There is no failure webhook, so poll for failed.
The SDK's submitTranscription, submitVisionAI and getComputeStatus return 401 with access-key credentials (SDK 1.9.4–1.9.6). They call /v1/api/compute/…, which only accepts a signed-in dashboard session. Use the /v1/api/compute-api/… calls above until an SDK release says otherwise.

Reading the ZibSidecar

The CDN decrypts the sidecar on the fly. Resolve a signed URL for sidecar_file_id on your server (Object URLs), fetch it and parse it as JSON.

javascript
// objectUrl(id): your server resolves a signed URL — GET /v1/api/object-url (Bearer, owner only)
const objectUrl = async (id) => (await fetch(`https://api.mosnet.io/v1/api/object-url?file_id=${id}`, {
  headers: { Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}` },
}).then((r) => r.json())).url;

// status from GET /v1/api/compute-api/job/{job_id} (above)
const sidecarUrl = await objectUrl(status.sidecar_file_id);

// Fetch and parse
const sidecar = await fetch(sidecarUrl).then(r => r.json());

// Key fields:
sidecar.scene_understanding.executive_summary        // 150-200 word summary
sidecar.content_classification.garm.brand_safety_score  // 0.0–1.0
sidecar.clip_suggestions[0].virality_score           // 0.0–1.0
sidecar.chapters_youtube_format                      // paste into YouTube description
sidecar.transcript.full_text                         // full transcript string
sidecar.transcript.segments[0].intent               // hook / payoff / cta / ...
sidecar.content_classification.iab_categories[0].name  // IAB 3.0 taxonomy

Quality tiers

Transcription
fastest
Highest throughput
Good for short clips, lower accuracy
recommended
Balanced — default
Best accuracy/speed balance
accurate
Highest quality
Best accuracy, slower processing
Vision AI
standard
Default
Fast, good quality scene analysis
hq
Higher quality
Deeper analysis, slower processing

Deep-dive docs

Publishers (Distribution)

A publisher is an app or service that displays ZiB-hosted titles to its own audience — distinct from a creator (who uploads content) and a customer(a storage account). Creators' ingest + encode is on ZiB; as a publisher you cover the delivery egress for what your viewers watch, paid from a prepaid USDC balance that decrements in near-real-time.

Two credentials, two places. Your publisher API key (ZIBPK_…:secret) is server-side only — your backend uses it to read the catalogue and mint playback sessions. The session token it returns is the only thing that touches the browser. Never ship the API key to a client.

1 · Onboard

Sign in at app.zibnetwork.com/publisher and submit your details. A ZiB admin reviews and activates the account. Once active, generate your API key from the dashboard (the secret is shown once) and fund your balance.

2 · Read the catalogue (server-side, API key)

Your backend pulls the published titles you are allowed to carry. Carriage is creator-controlled: a creator allows a publisher to carry all of their published content, and a title appears in your catalogue while it is published and its creator allows you. A creator who unpublishes a title or withdraws the allowance removes it from your catalogue. A published title whose main video is not ready yet is listed too, as "coming_soon" (below).

bash
curl "https://api.mosnet.io/v1/api/publisher/catalog?limit=500" \
  -H "Authorization: Bearer $ZIB_PUBLISHER_KEY:$ZIB_PUBLISHER_SECRET"

# → { "count": N, "titles": [ {
#      "type": "title",
#      "id": "<title_id>",            // stable — store this
#      "file_id": "<file_id>",        // the title's CURRENT encode; changes on re-encode; null when coming_soon
#      "title": "...", "slug": "...", "content_type": "movie", "description": "...",
#      "creator_id": "<creator_id>",  // stable — key creator logos and grouping on this, not the slug
#      "creator": "...", "creator_slug": "...",
#      "creator_logo_url": "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=...",
#      "thumbnail_horizontal_url": "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=...",
#      "thumbnail_vertical_url":   "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=...",
#      "duration_seconds": 5400, "genres": [...], "content_rating": "...", "language": "...",
#      "availability": "live",        // or "coming_soon"
#      "window": null, "view_count": 0,
#      "playback": {                  // null when coming_soon
#        "hls_url": "https://cdn.mosnet.io/<file_id>/master.m3u8",
#        "poster_url": "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=...",  // the title's image, or null
#        "playability_url": "https://api.mosnet.io/v1/api/content/<title_id>/playability"
#      },
#      "trailer": {                   // null when the title has no trailer (or its upload never finished)
#        "file_id": "<trailer_file_id>",  // mint the trailer with this; read it fresh each time
#        "ready": true,               // false while it is still encoding
#        "hls_url": "https://cdn.mosnet.io/<trailer_file_id>/master.m3u8",
#        "poster_url": "https://cdn.mosnet.io/objects/<uuid>?exp=...&tok=..."   // the title's image, or null
#      }
#    }, ... ] }

Filters: ?content_type=, ?creator=<slug>, ?availability=coming_soon|live, ?limit= (default 500, max 2000). Episodes of a series appear here as individual titles. Filters apply before limit, so ?availability=coming_soon&limit=20 returns up to 20 coming_soon titles.

  • availability is "live" or "coming_soon". A live title has a main video and a playback block. A coming_soon title is published but has no main video yet: file_id and playback are null, and its art is signed as usual. It becomes live on a later read once its video is ready. window is always null (licensing windows were replaced by creator carriage on 2026-06-22), and ?availability=licensed returns nothing.
  • Show coming_soon titles everywhere you show titles: New, Trending, Most Votes, search and any other row, with their poster. When a viewer opens one, your player shows “Video coming soon” and does not mint a playback session. Branch on availability (or playback === null) before calling playback-session; a mint for it returns 404 “title has no current encode”.
  • Key creator logos on creator_id, not creator_slug. A slug can change; the id does not. creator_logo_url is signed like the thumbnails.
  • Art URLs are signed when the response is built, with the same lifetime as object URLs (expiry quantised to the hour, 11–12 h left when issued). A catalogue fetched in the same hour returns byte-identical URLs, so the “cache to the top of the next hour” rule applies. Use each URL as returned; the thumbnail host can be either CDN name.
  • Render art from the catalogue every time; never store an art URL. Do not save a cdn.mosnet.io/objects/… or cdn.zibnetwork.com/objects/… URL in your database, with or without its token. A stored URL expires or stops loading, and the result is a blank poster. Store the title id and creator id, and take the art from your latest catalogue read (cached to the top of the hour).
  • playback.poster_url is the title's image: the same signed art as thumbnail_horizontal_url, or the vertical thumbnail if there is no horizontal one, or null. (Before 2026-10-31 it pointed at the video file.)
  • trailer is the title's own trailer, at the top level of the entry rather than inside playback, because a coming_soon title can have a trailer too. It is null when the title has no trailer or its upload never finished, so you never get a link to an upload that does not exist. ready: true means it plays now; use hls_url only then. false means it is not playable yet (still encoding, or its encode failed): show nothing or “coming soon” and re-read the catalogue later. To play it, mint a session with file_id: trailer.file_id (step 3). trailer.poster_url is the title's image, the same as playback.poster_url. When the creator replaces the trailer, trailer.file_id changes once the new one is ready, so read it from the catalogue each time rather than storing it.
  • Store the id (title id), not the file_id or any URL. A re-encode gives the title a new file_id; the title id never changes.

Art and lookups for a publisher

GET /v1/api/object-url only signs objects owned by the calling account. Creator files are not yours, so a publisher cannot use it, and there is no publisher equivalent. A publisher gets art the same way it gets titles: re-fetch the catalogue (or the series below) and use the signed URLs in that response, within their lifetime.

  • No per-title lookup. There is no publisher route that returns one title by id. To refresh a single title's art you re-read the catalogue (filter by ?creator= to narrow it) and cache it to the top of the hour. This is a known gap; if you need a per-title route, tell us.
  • Series: GET /v1/api/publisher/series?id=<series_id> (API key) returns one series with its seasons and episodes, resolved live:
bash
curl "https://api.mosnet.io/v1/api/publisher/series?id=$SERIES_ID" \
  -H "Authorization: Bearer $ZIB_PUBLISHER_KEY:$ZIB_PUBLISHER_SECRET"

# → { "id", "title", "slug", "description", "series_type", "creator_id", "creator_org_id", "status",
#     "poster_horizontal_url", "poster_vertical_url", "genres", "content_rating",
#     "release_year", "cast_members", "director", "language",
#     "seasons": [ { "id", "season_number", "title", "description", "status",
#                    "release_date", "release_year", "content_rating", "language",
#                    "cast_members", "director", "poster_horizontal_url", "poster_vertical_url",
#                    "trailer_file_id", "episode_count",
#                    "episodes": [ { "id", "episode_number", "title", "thumbnail_horizontal_url",
#                                    "duration_seconds", "status", "primary_file_id", "playable" } ] } ] }
# 404 { "error": "series not found" } unless the series is published and you carry its creator
#     (the same answer as for an unknown id).
The series response is gated like the catalogue. You get the series only if it is published and you carry its creator; anything else is the same 404 as an unknown id. Only published episodes are listed. Seasons are not filtered by status, so a season can appear with status: "draft": show an episode only when it has playable: true, and a season's trailer only when the season is status: "published" (a draft season's trailer cannot be minted). The series and season posters and the episode thumbnails are signed, with the same lifetime as the catalogue's art; render them from a fresh read and never store them. creator_id is the same id as in the catalogue. Mint playback for an episode by its id (title id), as below.
See also Links that outlive 12 hours under Object URLs if this art goes into og:image or feeds.

3 · Mint a playback session (server-side, balance-gated)

When a viewer hits play, your backend mints a short-lived session for content you carry. If your balance is empty this returns 402 — no token, no playback (the balance funds the node operators who serve the bytes, so it can't go negative). Send the title id (title_id): ZiB resolves the title's current encode at mint time, so a re-encode never strands you on a dead file_id. file_id is still accepted, used only when title_id is absent, and goes stale after a re-encode. It must be a file the catalogue or the series response hands you (a title's trailer is trailer.file_id in its catalogue entry): the primary or trailer file of a published title you carry, the trailer of a published series you carry, or the trailer of a published season of one. Any other file id is refused. To play a trailer, send its file_id: title_id always mints the title's main video.

javascript
// Your server — the secret key stays here.
// entry = this title's object from your latest catalogue read
if (entry.availability !== 'live') return showComingSoon();  // coming_soon: never mint
const res = await fetch('https://api.mosnet.io/v1/api/publisher/playback-session', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ZIB_PUBLISHER_KEY}:${process.env.ZIB_PUBLISHER_SECRET}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title_id: entry.id,    // the catalogue entry's "id" — preferred
    // file_id,            // back-compat only, used when title_id is absent
    // ttl_minutes: 180,   // optional; default 180, clamped to 5–720
  }),
});
if (res.status === 404) return markUnavailable(title_id); // unpublished, carriage withdrawn, or no encode yet — not retryable
if (res.status === 402) return renderTopUpPrompt();
const { session_token, expires_at, file_id, hls_url, key_url, credit_balance_usdc } = await res.json();
// file_id is the RESOLVED current encode — use it for the SDK meter / multi-source.
// send session_token + hls_url (+ file_id) to the browser

Errors: 400 when neither id is sent; 404 “title not found” when the title is unknown, not published or not carried by you (one answer for all three); 404 “title has no current encode (primary_file_id is null)” for a carried, published title with no encode yet; 404 “file not found” for a file_id outside the set above; 402 with credit_balance_usdc when the balance is zero or below. The 404s are checked first, so a 402 means the content was allowed and only the balance stopped it.

Handling a 404. Your cached catalogue can go stale: a creator can unpublish a title or withdraw your allowance at any time, and the next mint for it returns 404. Treat a 404 as “not playable now”, not as an outage. Do not retry the same request, and do not fall back to an older file_id, because that is refused the same way. Hide the title or show it as unavailable, and re-read the catalogue: it appears again if it is republished or the allowance returns. The one 404 that can clear by itself is “title has no current encode”: that is a coming_soon title, which you should not be minting for. Show “Video coming soon”, and play it once a later catalogue read shows it live. A 404 “series not found” from the series lookup works the same way: drop the series card and re-read the catalogue.

Geo is separate. Carriage puts a title in your catalogue; it does not override per-viewer geo rights. Always resolve playability per viewer (next step).

4 · Check geo-playability (per viewer)

Call the open playability endpoint from the viewer's browser (so ZiB sees the viewer's IP) before playing.

javascript
const p = await fetch(`https://api.mosnet.io/v1/api/content/${titleId}/playability`)
  .then(r => r.json());
if (!p.playable) showUnavailableInRegion(p.userCountry);

5 · Meter egress in the browser (SDK)

Video segments are served direct from ZiB nodes, so the player reports what it fetched. Start the meter with the session token; it decrements your balance in near-real-time and stops playback when you run dry. Main-thread fetches auto-meter; worker-based players (hls.js / bitmovin / shaka) call recordSegment() from their segment-loaded event.

html
<script src="https://app.zibnetwork.com/sdk/zib-sdk.js"></script>
<script>
  const meter = ZiBStorage.startPublisherMetering({
    sessionToken: "<session_token>",   // from step 3
    fileId: "<file_id>",               // the file_id the session returned
    onExhausted: () => player.pause(),  // balance hit zero
  });

  // hls.js — report each loaded fragment for anti-spoof, server-derived billing
  hls.on(Hls.Events.FRAG_LOADED, (_e, d) =>
    meter.recordSegment({ quality: d.frag.level, index: d.frag.sn, bytes: d.stats.total }));

  // on teardown:
  // meter.stop();
</script>
Bytes are derived from ZiB's own segment sizes when you pass quality + index, so a client can't under- or over-report. Without them, the SDK falls back to observed bytes capped at the title's real size.

6 · Top up & monitor

Fund your balance with USDC from the dashboard (deposited to the ZiB escrow, same rail as creators). Egress is billed at the standard retrieval_per_gb rate. Your backend can poll GET /v1/api/publisher/balance and GET /v1/api/publisher/usage/live (last-60-min bytes) — both with the API key — to surface balance + near-real-time usage in your own UI.

Keep the balance funded. At zero, session minting returns 402 and in-flight sessions stop at the next beacon — playback halts for your viewers until you top up.

Object URLs

Posters, sprite sheets, subtitle tracks and source files are served from cdn.mosnet.io/objects/{file_id}?exp=…&tok=… (cdn.zibnetwork.com serves the same paths). The URLs ZiB hands you are signed and short-lived, so there is one rule to follow: store the file_id, not the URL.

A signed URL expires after 12 hours. If you save an object URL in your database and render it later, it will start returning 403. Save the file_id instead — those are stable forever — and resolve a URL when you render.

Resolving a URL

Same Bearer credentials as the rest of the API. Scoped to your own content — a file_id you don't own returns 404.

bash
curl -H "Authorization: Bearer $ACCESS_KEY:$SECRET_KEY" \
  "https://api.mosnet.io/v1/api/object-url?file_id=$FILE_ID"

{
  "file_id": "...",
  "url": "https://cdn.mosnet.io/objects/...?exp=...&tok=...",
  "expires_in_seconds": 41180        // computed, not a constant — see below
}

Resolving is cheap. Call it when you render rather than caching the URL until it expires.

The URL is stable for an hour, and that is on purpose

expires_in_seconds is the real remaining life, computed per response — not a fixed 12 hours. It is smaller than 43200 for most of any given hour, and here is why that matters. The expiry we sign is quantised to the wall-clock hour, so resolving the same file_id twice inside the same hour returns a byte-identical URL — same exp, same tok. The token is then valid for 12 hours from the top of that hour, so anything you resolve has between 11 and 12 hours of life left.

This matters if an image optimiser sits between you and the CDN. Next.js next/image, Cloudinary and friends key their cache on the URL, so a URL that changed per request would make them re-fetch and re-encode every poster on every page view. Because the URL only changes once an hour, they do not. If you cache the resolved URL yourself, cache it to the top of the next hour — that is the longest window in which it cannot change, and it still leaves 11 hours of validity. Do not cache for the full 12 hours: you would eventually hand out a URL that expires while someone is looking at the page.

Resolving many at once

A catalog grid rendering twenty posters should not be twenty round trips. Post up to 100 ids in one call. Ids you do not own — or that do not exist — come back null rather than failing the whole batch, so one bad id does not cost you the other nineteen.

bash
curl -X POST -H "Authorization: Bearer $ACCESS_KEY:$SECRET_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"file_ids":["<uuid-a>","<uuid-b>"]}' \
  "https://api.mosnet.io/v1/api/object-urls"

{ "urls": { "<uuid-a>": "https://cdn.mosnet.io/objects/...?exp=...&tok=...",
            "<uuid-b>": null },
  "expires_in_seconds": 41180 }

Rate limits: none are applied today. That is a statement of fact rather than a guarantee — if your volume is high enough that a limit would hurt, tell us the number before you build against the absence of one.

When resolving fails at serve time

An ad server or API that resolves URLs per request needs a plan for the call failing:

  • Never fall back to an unsigned URL. A hand-built /objects/<uuid> may work today and 403 later with no change on your side, which is the silent failure this page exists to prevent.
  • Reuse a URL you already resolved, if it has not expired. Its exp query parameter is the expiry in unix seconds; a short cache (to the top of the next hour, see above) means most renders never call out at all.
  • Network error or 5xx: retry once or twice with a short backoff, then omit the asset (skip the creative, render a placeholder) rather than serving a broken link.
  • 401: your credentials are wrong — fix configuration; retrying will not help. 404 (or null in the batch form): the file is not yours or no longer exists — stop serving it and check whether it was replaced (re-upload issues a new file_id).

References inside a signed document

A sprite VTT is a document whose cues are object URLs, pointing at the sprite sheets. Those are signed for you when the VTT is served — absolute URLs, minted per response, with the same lifetime as any other, and that response is cached for an hour so it can never outlive its own tokens. You do not need to resolve them yourself.

Worth knowing if you post-process a VTT: because the refs are absolute, a new URL(ref, vttUrl) style resolution is harmless here. If they were relative, that call would drop the query string and produce an unsigned URL — the VTT would parse, every image would 403, and nothing would render. Signing nested references absolutely is deliberate, to keep that class of bug impossible.

Server-side retrieval

This endpoint is plain HTTP with Bearer credentials, so it is callable from an ad server, a backend job, or a server-rendered route — anywhere you can make a request. It is the supported server-side path, and it is the answer to “how do I get a URL without a browser”: the SDK's fetchObject() decrypts client-side and cannot help you here, but nothing about resolving a URL requires the SDK.

Links that outlive 12 hours (og:image, feeds, VAST, email)

Social crawlers (Slack, X, iMessage, Facebook), syndicated feeds, cached VAST and email templates keep a URL long after you generated it. A signed object URL expires within 12 hours, and an expired one fails silently: the markup is valid, the card or impression is counted, and the image is blank. There is no longer-lived ZiB token today. Put a URL on your own domain in that markup, and have it resolve a fresh signed URL each time it is fetched:

javascript
// e.g. og:image = https://yourapp.com/og/<title-or-file-id>.jpg  (stable, never expires)
// app/og/[id]/route.js — Next.js route handler
export async function GET(_req, { params }) {
  const fileId = await lookupPosterFileId(params.id);        // your DB: id -> current file_id
  const r = await fetch(
    `https://api.mosnet.io/v1/api/object-url?file_id=${fileId}`,
    { headers: { Authorization: `Bearer ${process.env.ZIB_ACCESS_KEY}:${process.env.ZIB_SECRET_KEY}` } },
  );
  if (!r.ok) return new Response(null, { status: 404 });     // never fall back to an unsigned URL
  const { url } = await r.json();
  const img = await fetch(url);                              // proxy the bytes …
  return new Response(img.body, {
    headers: {
      'Content-Type': img.headers.get('content-type') ?? 'image/jpeg',
      'Cache-Control': 'public, max-age=3600',               // … and let crawlers/CDNs cache them
    },
  });
}
  • Proxy the bytes, as above, rather than redirecting. Some crawlers do not follow redirects for images, and a crawler that caches the redirect target caches a URL that expires.
  • Publishers: /v1/api/object-url only signs your own files. Resolve the art from a catalogue response instead (cached to the top of the hour), and proxy it the same way.
  • If proxying is not possible for your case, tell us the use case. A longer-lived token for public previews does not exist today.

What is not affected

HLS playback does not use this path. Manifests are generated per request and sign the key URI inside them; segments are encrypted and addressed by content hash. Uploads, credentials and webhook payloads are unchanged. If you only stream video, or you already resolve URLs at render time, there is nothing to do.

Enforcement: what is judged, and how to test it

Refusing unsigned object reads (“enforcement”) is a server-side switch. It can be set for the whole platform or for one account. It was enabled platform-wide briefly on 2026-08-19 and switched back off within the hour: bare object URLs were embedded in live integrations built against the previous guidance on this page, which was our error, not theirs. It will not be switched on platform-wide without coordinating with every integration first, and the switch will be announced in the changelog. Build as if it is on.

A presented token is always judged, whether or not enforcement is on. Only an absent token can get the lenient path, and only while enforcement is off for the object's owner. So a deliberately bad token shows you the real behaviour:

bash
# The &cb= is not decoration — see the warning below.
curl -i "https://cdn.mosnet.io/objects/$FILE_ID?exp=1&tok=bad&cb=$RANDOM"   # 403 — a bad token is always refused
curl -i "https://cdn.mosnet.io/objects/$FILE_ID?cb=$RANDOM"                 # 200 while unsigned reads are allowed for
                                                                           # this object's owner; 403 once they are not
Add a cache-buster, or you may be testing our CDN instead of the gate. A successful object response is cacheable for 24 hours, per edge location. The URL this page used to publish — ?exp=1&tok=bad — was fetched by many people while the gate was still lenient, so some edges hold a cached 200 for exactly that string and will serve it back long after the origin started refusing it. A tenant reported this as exp=1 being special-cased; it is not, and the origin cannot tell exp=1 from exp=1000 — both are simply in the past. A unique query parameter forces the request to reach us.

The same effect is worth planning for at switch-on: a URL that was cached while unsigned keeps serving from the edge for up to 24 hours afterwards, so enforcement will look partially applied for a day. Check cf-cache-status and age before concluding anything from a 200.

Until 2026-08-19 the first command returned 200, so this rehearsal proved nothing. Thanks to the tenant who measured it rather than trusting the instruction.

Rehearsing the real switch

A bad token proves the gate works; it does not prove your code has stopped sending bare URLs. For that we can enable enforcement across your content only, leaving every other tenant untouched, so you can verify in your own staging or production before the global switch. Ask us and we will add your customer id to the opt-in list — and remove it the moment you say so.

Streaming URLs are not affected

/stream/{file_id}/master.m3u8 is safe to store and is not moving behind a token. (Encoding produces HLS only. There is no DASH output: /stream/{file_id}/manifest.mpd answers 404 for anything encoded today and dash_manifest_url is null, so do not store, emit or advertise an .mpd URL.) The distinction is worth understanding, because it is the rule rather than an exception: a manifest is generated fresh on every request, so the signed URIs inside it — the AES key, the subtitle playlist — are minted at fetch time and are never stale. An object URL, by contrast, carries its own credential, which is why storing one goes bad. Store a URL that is resolved when it is fetched; do not store a URL that already contains a token. (Storing a manifest's body would break for the same reason as an object URL.)

Errors & Troubleshooting

Real status codes you'll see, what they mean, and what (if anything) to do about them. Every ZiB error response is JSON with an error field describing the cause.

HTTP status reference

400Bad Request

When you see it: Malformed body, missing required field, invalid bucket name, file_size of 0, or pipeline stage requested for a non-existent tier (e.g. transcription "ultra"). The error message names the specific field.

What to do: Fix the request body. Not retryable as-is.

401Unauthorized

When you see it: Bearer token missing, malformed, or wrong format. Master credentials use the format `<accessKey>:<secretKey>` in the Authorization header, not OAuth-style.

What to do: Verify ZIB_ACCESS_KEY / ZIB_SECRET_KEY in your environment. Regenerate via the Storage UI if you suspect rotation.

403Forbidden

When you see it: Credentials are valid but you don't own the resource (customer_id mismatch, bucket belongs to a different tenant).

What to do: Check the customer_id / bucket scoping. Cross-tenant operations need separate credentials.

404Not Found

When you see it: file_id, encoding_id, customer_id, or bucket does not exist (or was deleted). On the CDN, a 404 on `/master.m3u8` means no segments exist yet for that file_id — either the encode hasn't finished or no encode was ever requested.

What to do: Verify the ID. For CDN 404s during an active encode, poll `/v1/api/pipeline/<file_id>` to see actual phase.

409Conflict

When you see it: Two cases. (1) An upload session that has already completed: once /upload/{upload_id}/complete succeeds the session is closed, and a later /part/{n} or /complete for that id returns 409 { "code": "upload_closed", "file_id": … }. The session has already completed or can no longer accept parts. This is what a retry looks like when the first complete succeeded but its response was lost; uploadDirect() reports it as "Failed to complete multipart upload (409)". (2) Direct S3-path uploads where the (bucket, key) already exists with different contents.

What to do: For upload_closed: do not retry. Treat the file as uploaded only when you receive its file.ready webhook; a 409 alone does not prove the upload succeeded (and GET /v1/api/object-url cannot confirm it: it answers for any registered file_id, finished or not). To upload again, call /v1/api/upload/initiate for a new registration. For the S3 case: use `ZiBStorage.uploadDirect()`, or delete the existing key first or use a different key.

410Gone

When you see it: The upload registration has expired. Registrations are valid for 7 days from /upload/initiate; after that both /upload/{upload_id}/part/{n} and /upload/{upload_id}/complete refuse. This is 410 rather than 404 deliberately — the upload_id was real, so you are not looking for a typo.

What to do: Call /v1/api/upload/initiate again and upload against the new registration. Nothing is recoverable from the expired one; a partially uploaded object never completed and holds no bytes you can reach.

500Internal Server Error

When you see it: Unhandled exception on the backend. Always a ZiB-side bug. Should not happen in normal operation.

What to do: Retry once after a short delay. If reproducible, file a bug with the request_id from the response (when present) and the timestamp.

502Bad Gateway

When you see it: Almost never returned by ZiB directly. If you see this in your tenant proxy, it usually means YOUR proxy received a non-JSON response from ZiB and translated it to 502. The original error is in your proxy's stdout — don't treat the 502 as the bug, it's a relay.

What to do: Check your proxy logs (e.g. your Next.js API route) for the upstream response. The real status code + body tell you what ZiB actually returned.

503Service Unavailable

When you see it: "No storage nodes available" — the upload selector couldn't find a node satisfying all gates (storage_enabled, s3_enabled, mesh_reachable, public_url, storage_allocated_gb > 0). Fleet capacity is genuinely tight or all eligible nodes recently went offline.

What to do: Retry with exponential backoff (10s, 30s, 90s). The fleet recovers on its own as nodes come back online. Persistent 503s for &gt; 5 min indicate a real outage — contact ZiB ops.

Tenant-proxy 502s — the relay trap

If your tenant has a Next.js / Express / Lambda proxy in front of ZiB (typical for browser clients that don't hold master credentials), be aware: that proxy almost certainly translates any non-JSON ZiB response into a generic 502. The browser then shows "Failed to initiate upload (502)" or similar.

The 502 is rarely the actual bug. It's a relay. The real cause is in the proxy's server-side log:

  • If the proxy logs ZiB returned 503 — fleet capacity, see 503 above.
  • If ZiB returned 401 — master credentials wrong on the proxy.
  • If ZiB returned 5xx HTML — temporary backend issue, retry.
  • If connection refused / timeout — proxy can't reach ZiB; check egress firewall.

Surface the upstream status code in your proxy's 502 response body. Future-you debugging at 3am will thank you.

Common silent failures

These don't throw an error, but produce wrong behaviour. All have bitten real tenants:

  • Missing webhook_url in the request body. If you use per-request webhook config but forget to include the URL on a particular call, ZiB stores null, no events fire, and there's no error. Verify with SELECT webhook_url FROM encoding_jobs WHERE id = '...' if you have DB access, or check your request-builder for conditional logic that drops the field.
  • Polling that never stops on terminal phase. If your code treats any non-error response as "keep polling", you'll hammer the endpoint forever after the encode is done. Always break on phase === 'safe' / 'failed_user_action' / 'cancelled'. See Video Encoding section.
  • Routing logic that keys on encoding_id. Re-encode requests can return a different encoding_id if forced or after supersede. Always route by file_id in your storage and references; treat encoding_id as job-ephemeral.
  • Reading status when you should read phase. Both fields are present in responses; they mostly agree but phase is canonical. Code keyed on status may miss new phase values added in the future.
  • Webhook handler that 200s before persisting. ZiB retries on non-2xx but stops on 200. If your handler returns 200 then crashes before writing the event to your DB, you've dropped it. Persist first, ack second.

Getting help

When opening a support thread, include:

  • customer_id and the specific file_id / encoding_id involved.
  • UTC timestamp of the failing request (within ±30 s is plenty).
  • The full request body (redact sensitive fields like webhook_secret) and the response status + body.
  • If a proxy is involved, the upstream response your proxy received from ZiB — not just the 502 your code saw.