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.
initiatealways returnsupload_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.
How It Works
Every file uploaded to ZiB goes through a four-stage pipeline before it is available on the network.
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.
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.
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.
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.
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.
.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.Load the SDK
Add the SDK script to your HTML. No npm package or build step required.
<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:
<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.
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.
// 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);
}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.
// 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.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.
// 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.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.
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.
{
"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.
}
}SDK Reference
The ZiB SDK is a single browser-side JavaScript file with no dependencies. Load it once via script tag.
<script src="https://app.zibnetwork.com/sdk/zib-sdk.js"></script>Primary upload method
ZiBStorage.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.Promise<{ 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.
ZiBStorage.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).Promise<Blob> — use URL.createObjectURL for media, or .arrayBuffer() / .text()ZiBStorage.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.Promise<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.)
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.
Promise<string> — S3-compatible XMLstorage.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.Promise<string> — S3-compatible XMLstorage.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.Promise<{ 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.Promise<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.Promise<{ 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.Promise<{ 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.string — `${cdnEndpoint}/objects/<uuid>`, i.e. https://cdn.zibnetwork.com/objects/<uuid> with the default constructorServer-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:
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
// 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
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
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
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/initiateand 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 withBearer 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
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”). permissionsis required (an empty list is refused with400). Anything other thanWRITEis 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:
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.
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
| Host | What for | CSP directives |
|---|---|---|
| app.zibnetwork.com | The 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.io | Signed object URLs (posters, sprites, sidecars, files), HLS manifests, keys and subtitle playlists. | img-src, media-src, connect-src |
| cdn.zibnetwork.com | The 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.com | Storage 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.com | Only 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.
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.
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.
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.
// 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:
// 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.
// 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.80on 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.
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
{
"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
encodeHLS video transcoding (H.264 multi-bitrate). Required for all video uploads that will be streamed.
transcriptionSpeech-to-text transcription with three quality tiers. Produces SRT and VTT subtitle files served from the CDN.
visionAI 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.
// 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.
{
"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) orcancelled(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.nulluntil the safety sweep flipsphasetosafe.cancelled_reason— present only whenphase === '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.
/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).
Flow
- Your server calls
POST /v1/api/upload/pin/createwith the same params as/upload/initiate(bucket, key, pipeline). Returns{ pin, session_id, expires_at }. - You display the 3-word PIN to the user (and optionally a QR code).
- The user opens the ZiB Node desktop app, clicks Upload → Enter PIN, types the PIN, and chooses a file.
- The node app encrypts + shards the file locally and registers it with the network.
- Your server polls
GET /v1/api/upload/pin/status/{session_id}untilphase: "ready"(or use the SDK helper below). - The same
file.readywebhook fires, the encoding pipeline runs as normal, and you receiveencoding.completeon your existing webhook URL (file.readygoes to the account-level URL only — see Webhooks).
Server: create a PIN
// 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
// 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
{
// 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
}
}/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's node uploads, the network handles the rest.prioritizePrefer your own user's nodes for compute, fall back to others if needed. The redeeming node will see a soft warning.strictOnly your own user'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.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
Quality variants are produced: 360p, 480p, 720p, 1080p, and 2160p where source resolution allows. Each variant is a separate HLS stream.
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.
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.
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
// 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 playerencoding.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.
// 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);
});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.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.completemay 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:
- Null-guard the response. Between the moment your
/v1/api/encoding/requestreturns and the moment the encoding job row materialises (typically <1 s), the poll endpoint can return an empty body. Useresult?.phase, notresult.phase— otherwise your first poll throws a TypeError. - 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. - Stop on the terminal top-level
status.GET /v1/api/pipeline/{file_id}returns a top-levelstatus— poll that, not a per-stage field:status === 'complete'— success. The HLS manifest is live atdata.stages.encoding.hls_manifest_url(always/stream/{file_id}/master.m3u8).data.cdn_urlis a signed URL for the uploaded source file, not the stream.status === 'failed'— terminal failure. Readdata.error; show a Retry button.- In-progress values are
queued | uploading | encoding | ai_processing— keep polling.
data.stages?.upload?.phase === 'cancelled'and switch your UI to the newfile_id. - On
/pipeline, use the top-levelstatus. The/pipeline/{file_id}rollup exposesstatusat the top level and aphaseonly onstages.upload— there is nostages.encoding.phase, andstatusnever returnssafe. (Thephasevocabulary —safe,failed_user_action— belongs to a different endpoint,GET /v1/api/my-jobs.) - 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.
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
<!-- 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:
<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>installGlobalMultiSourcepatches the page's globalXMLHttpRequest— one title per page, and it cannot reach a player that fetches in a Web Worker (usecreateHlsLoaderfor hls.js).- The helpers call
GET https://api.zibnetwork.com/stream/{file_id}/holdersand then read segment bytes fromzb-*.zibnetwork.comnodes — both must be inconnect-src. - The video.js and global helpers also keep the AES-128 key in memory for up to its
Cache-Controlmax-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 tohttps://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 thehtml5options to create the player with. On desktop browsers whose Media Source Extensions can play ZiB's codecs it addsvhs.overrideNative: true,nativeAudioTracks: falseandnativeVideoTracks: 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 inplayer.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 inlocalStorageunderzib.player.quality(storageKey: 'other-key'renames it,storageKey: falseturns 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.
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.
installQualityMenuneeds the player, so call it inplayer.ready, for each player you create.installVhsMultiSourceis 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 itsexpires_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 itsdetach()beforedispose(), and attach again to the new player's video element with the samefileIdand token.
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.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:
// 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:
// 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
}),
});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/requestas idempotent — calling it twice for the samefile_idis safe and cheap. - Don't add your own retry loop on top. If the response is
already_queuedoralready_encoded, store the returnedencoding_idand 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.readywebhook 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 thatfile_idbefore callingstartEncoding.
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.
// 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.completewebhook 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.
// 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>"
}
}_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:
// 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 });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: falseand 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.
// 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.completewebhook 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.
// 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.
// 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.
}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.
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.
// 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.
// 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,
});
}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
curlagainst it returns 401. - Your re-encode endpoint refuses with 409 if your own DB says encoding is in progress for that content.
- Your
file.readyhandler looks up rows by(bucket, object_key), not byfile_id.file_idchanges on every re-upload; the key does not. - Your
file.readyhandler treats a webhook whosefile_iddiffers from your storedzib_file_idas a re-upload, fires a fresh encoding request, and writes the newfile_idback to your row. Idempotency is keyed onfile_id, never onencoding_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'sencoding_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 theencoding_idand 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.
What gets sent
node_id— theZB-XXXXXXID parsed from each segment URL hostname (zb-*.zibnetwork.com)ttfb_ms,total_ms,size_bytesfrom 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.
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.
<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>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.
// === 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.
// On player init, after the <video> element is in the DOM
const detach = ZiBStorage.attachToVideo(videoEl, { fileId: '<file-uuid>' });
// On player teardown
detach();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-Originechoes the requestOrigin(not*) so thatnavigator.sendBeacon()works — sendBeacon is credentialed by spec, and browsers reject wildcard origins on credentialed responses.Access-Control-Allow-Credentials: trueis 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: Originis 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.
ZiBStorage.startTelemetry({ viewerSessionId: '...' });React / Next.js — mount in the root layout
/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.
// 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>
);
}// 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 anisEditModeflag (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
- 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.
- 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. - Switch the filter to
telemetryand play a video for ~30 s. You should see a POST to/v1/api/telemetry/segment-fetchevery 30 s or every 10 segments, returning{ accepted: <n>, rejected: 0 }. Confirm the segments themselves are coming fromzb-*.zibnetwork.comhostnames in the Domain column — that's the URL pattern the SDK matches. - Close the tab. The SDK fires a final
navigator.sendBeacononpagehide; that flush still shows up in DevTools' Network panel under Show navigation requests. - If you have admin access, your samples land in
/superadmin/networkwithin ~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:
| Response | Diagnosis | Fix |
|---|---|---|
| { 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 all | SDK 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.
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.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:
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.
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.
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.
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:
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 bodyEvents
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).
// 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"
}// 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"
}// 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"
}// 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"
}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.// 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 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.(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.readyfor a key you've seen before, treat it as a hard reset: clear any state stored against the previousfile_idfor that key, including any priorjob_id, CDN URLs, manifests, and progress. - Do not maintain a fallback that matches webhooks by
job_idalone — a stalejob_idfrom 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 newfile_idis 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 cancelleds3_objectsrow 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.
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');
}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:
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))));
}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.
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.
# 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.webpResponse shape
{
"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.
// 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
cancelledbefore the bytes go away. The encoder won't deliver any furtherencoding.*webhooks for thatfile_id. - CDN cache purged. ZiB calls Cloudflare's purge API for
/objects/{file_id}and/stream/{file_id}/on both CDN hosts (cdn.mosnet.ioandcdn.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/bucketsand your storage usage dashboard.
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}.
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.{
"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.
// 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.
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.failedwithcancelled_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 + firesfile.deleted.
file.deleted webhook receipt log if your compliance posture requires a second-system attestation.Security Model
- 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
- Keep
access_keyandsecret_keyon 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-Signaturebefore 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.
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.
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.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.
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).
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 itencoding— FFmpeg is running;phase_progress_pctticks 0–100sharding— encoded segments are being hashed + written to storagelocal_complete— all bytes on the originating node, awaiting backend greenlightreplicating— segments are being pulled to peer nodessafe— ✅ 3+ peers confirmed; the file is durably stored and will survive any single-node failure.replication_safe_atis set.failed— transient failure, auto-retrying (retry_aftertells when)failed_user_action— retries exhausted; surface a Retry button to the usercancelled— user-cancelled; derivatives being cleaned up
List jobs
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
{
"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
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
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-jobsfor 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.iowith 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.
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.
// 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:
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:
400if neithertranscriptionnorvisionis sent, or the id is not a UUID;404if the file is not yours. - Tier names are not validated when you submit. For transcription,
fast/balanced/accurateand the aliasesfastest/recommendedare recognised; anything else runs asbalanced. For vision, a job is only picked up by a node that advertises that exact name. Usestandardorhq, because any other value can stayqueuedindefinitely. - Webhooks: a standalone job sends
transcription.complete(withsrt_file_id) and/orvision.complete(withsidecar_file_id), each withevent,job_id,file_id,timestampand theX-ZiB-Event/X-ZiB-Job-IDheaders. They go only to thewebhook_urlin this request: there is no account-default fallback. They are signed only if you sent awebhook_secret. There is no failure webhook, so poll forfailed.
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.
// 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 taxonomyQuality tiers
fastestrecommendedaccuratestandardhqDeep-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.
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).
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.
availabilityis"live"or"coming_soon". Alivetitle has a main video and aplaybackblock. Acoming_soontitle is published but has no main video yet:file_idandplaybackarenull, and its art is signed as usual. It becomesliveon a later read once its video is ready.windowis alwaysnull(licensing windows were replaced by creator carriage on 2026-06-22), and?availability=licensedreturns nothing.- Show
coming_soontitles 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 onavailability(orplayback === null) before calling playback-session; a mint for it returns404“title has no current encode”. - Key creator logos on
creator_id, notcreator_slug. A slug can change; the id does not.creator_logo_urlis 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/…orcdn.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_urlis the title's image: the same signed art asthumbnail_horizontal_url, or the vertical thumbnail if there is no horizontal one, ornull. (Before 2026-10-31 it pointed at the video file.)traileris the title's own trailer, at the top level of the entry rather than insideplayback, because acoming_soontitle can have a trailer too. It isnullwhen the title has no trailer or its upload never finished, so you never get a link to an upload that does not exist.ready: truemeans it plays now; usehls_urlonly then.falsemeans 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 withfile_id: trailer.file_id(step 3).trailer.poster_urlis the title's image, the same asplayback.poster_url. When the creator replaces the trailer,trailer.file_idchanges once the new one is ready, so read it from the catalogue each time rather than storing it.- Store the
id(title id), not thefile_idor any URL. A re-encode gives the title a newfile_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:
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).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.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.
// 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 browserErrors: 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.
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.
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.
<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> 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.
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.
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.
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.
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.
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 and403later 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
expquery 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
nullin 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 newfile_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.
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:
// 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-urlonly 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:
# 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?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.
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 RequestWhen 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.
401UnauthorizedWhen 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.
403ForbiddenWhen 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 FoundWhen 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.
409ConflictWhen 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.
410GoneWhen 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 ErrorWhen 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 GatewayWhen 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 UnavailableWhen 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 > 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_urlin 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 withSELECT 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 differentencoding_idif forced or after supersede. Always route byfile_idin your storage and references; treatencoding_idas job-ephemeral. - Reading
statuswhen you should readphase. Both fields are present in responses; they mostly agree butphaseis canonical. Code keyed onstatusmay 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_idand the specificfile_id/encoding_idinvolved.- 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.