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:
- →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 →
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.zibnetwork.com/objects/<uuid>, 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. Today the CDN URL is public and auth-free — the UUID itself is the access token. This is changing. An unauthenticated request can currently retrieve any object whose id is known, including source masters, so /objects/<uuid> is moving behind a signed, expiring URL. Build against Object URLs now: store the file_id and resolve a URL when you render. A URL you built by hand will 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 access via process.env.ZIB_ACCESS_KEY etc.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.7.2.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.zibnetwork.com/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.cdn_url);
// → https://cdn.zibnetwork.com/objects/<uuid>?exp=...&tok=...
// Store file_id, not the URL — 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: that path is moving behind a signed, expiring URL, and a stored URL will 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: string, cdn_url: string }>Reading objects direct from a node
For most reads, just use the CDN URL — it's a plain public URL and works in an <img> or <video> tag with no SDK at all. 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. Defaults to https://cdn.zibnetwork.com.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. These calls should be made from your server, not the browser — credentials are required.
new ZiBStorage({ accessKey, secretKey })Create an instance for account-level operations. Requires your tenant access key and secret key.
accessKeystringYour ZiB tenant access key.secretKeystringYour ZiB tenant secret key.storage.listBuckets()List all S3 buckets in your account.
Promise<string> — S3-compatible XMLstorage.listObjects(bucket)List all objects in a bucket.
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)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; prefer uploadDirect for anything user-facing.
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)Delete an object and remove all of its encrypted chunks from every node holding them.
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. Pass the file_id returned by uploadDirect. Encoding runs asynchronously — poll getEncodingStatus for progress.
fileIdstringThe file_id UUID returned by uploadDirect.Promise<{ encoding_id, status, hls_manifest_url }>storage.getEncodingStatus(jobId)Poll the status of an encoding job.
jobIdstringThe encoding_id returned by startEncoding.Promise<{ encoding_id, status, progress, hls_manifest_url }>storage.getCdnUrl(fileId)Builds the BARE object URL — no token. This is the URL shape that is moving behind a signed, expiring token, so anything you persist from it (or from the cdn_url field of uploadDirect / uploadAuto, which is built by this same call) will start returning 403 at switch-on. Safe for an immediate one-off fetch; wrong for storage and wrong for any markup that outlives the request. Store the file_id and resolve at render via GET /v1/api/object-url — see Object URLs.
fileIdstringThe file_id UUID.string — https://cdn.zibnetwork.com/objects/<uuid>Server-side Integration
The ZiB SDK runs in the browser. For server-side code (Node.js, Python, etc.) call the ZiB API directly with fetch or any HTTP client. All endpoints accept the same Bearer auth format.
Authentication
All authenticated endpoints accept a single header:
Authorization: Bearer <access_key>:<secret_key>Your access key and secret key are displayed once when you create a storage account. 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.zibnetwork.com/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 — list objects
import requests
headers = {"Authorization": f"Bearer {ZIB_ACCESS_KEY}:{ZIB_SECRET_KEY}"}
# List buckets
buckets = requests.get("https://s3.zibnetwork.com/s3/", headers=headers)
# List objects
objects = requests.get("https://s3.zibnetwork.com/s3/my-bucket", headers=headers)
# Start encoding
res = requests.post(
"https://api.zibnetwork.com/v1/api/encoding/jobs",
headers={**headers, "Content-Type": "application/json"},
json={"object_key": file_id, "transcription": "recommended"},
)
job = res.json() # { encoding_id, status, progress, ... }posters, and the four buckets we provision for you (video, posters, images, logos) collide with every other tenant’s by construction. An authenticated request resolves to your bucket of that name — on every S3-path endpoint, reads and writes alike, since 2026-08-28. That is why the examples above pass a Bearer header, and it is also why a name you do not own answers 404 NoSuchBucket rather than 403: you own no such bucket, and whether someone else does is not ours to tell you. An unauthenticated read of s3.zibnetwork.com/{bucket}/{key} cannot be disambiguated and resolves to the oldest bucket with that name, which may belong to someone else — so a 404 there can mean “wrong tenant’s bucket” rather than “no such object”. Never use that path as a public URL for your own assets; resolve an object URL from the file_id instead (see Object URLs).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.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.zibnetwork.com/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(auto-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.zibnetwork.com/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.zibnetwork.com/objects/a1b2c3d4-...",
"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/...m3u8" },
"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 on the same webhook URL.
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}untilstatus: "complete"(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.
Server: create a PIN
// Server-side only — your master Bearer credentials never reach the browser.
const res = await fetch('https://api.zibnetwork.com/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.
async function waitForCompletion(sessionId, onProgress) {
while (true) {
const res = await fetch(
`https://api.zibnetwork.com/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 on your existing webhook URL with identical payloads. 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 encrypted segment is its own content-addressed object and is replicated to at least three independent nodes — the same replication model used for all ZiB files: a node holds a given object completely or does not hold it.
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
// After uploadDirect() completes, pass file_id to startEncoding
const job = await storage.startEncoding(result.file_id);
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.
The payload carries a progress integer (0–100) and a stage field ("encoding" or "sharding") so you can optionally show which phase the job is in. The percentage is continuous across both phases — you don't need to track them separately.
// Webhook handler — store the progress value, render in your UI
app.post('/api/zib-webhook', (req, res) => {
const { event, file_id, progress, stage } = req.body;
if (event === 'encoding.progress') {
// progress is an integer 0–100
// stage is "encoding" or "sharding"
await db.update('videos', { encoding_progress: progress }, { where: { file_id } });
}
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 but does not auto-start a new one; your handler must call /v1/api/encoding/request for the new file_id, exactly as it did for the first upload. Pattern A below shows the correct shape.Edge cases
- Out-of-order delivery — under retry pressure, a lower percent may arrive after a higher one. Use
Math.max(current, incoming)if you want monotonic progress. - Fast encodes — small files or hardware-accelerated encodes may jump from 0 to 100 in a single event. The bar fills instantly — this is expected.
- No events arrive — if all quality variants finish near-simultaneously, you may only see the final
encoding.complete. Your UI should still work without progress events. - Sharding stage — if you want to show a separate phase label like "Distributing segments…", branch on
stage === 'sharding'. Otherwise just use the percentage as-is.
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. HLS manifest is live; usedata.cdn_url.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.zibnetwork.com/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') {
return { ok: true, manifestUrl: data.cdn_url };
}
// 3. Terminal: failure.
if (status === 'failed') {
return { ok: false, error: data.error ?? 'Encoding failed' };
}
// 4. Superseded by a re-upload to the same (bucket, key)?
// /pipeline maps 'cancelled' to failed at the top level, so detect
// supersede via the upload stage's own phase.
if (data?.stages?.upload?.phase === 'cancelled') {
return { ok: false, error: 'Upload superseded; check for the new file_id' };
}
// 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) -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<video id="video" controls></video>
<script>
const hls = new Hls();
hls.loadSource('https://cdn.zibnetwork.com/stream/<file_id>/master.m3u8');
hls.attachMedia(document.getElementById('video'));
</script>
<!-- Native HLS (Safari / iOS) -->
<video controls src="https://cdn.zibnetwork.com/stream/<file_id>/master.m3u8"></video>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.zibnetwork.com/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:
await fetch('https://api.zibnetwork.com/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.zibnetwork.com/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.zibnetwork.com/objects/<uuid>?exp=...&tok=...",
"sprite_url": "https://cdn.zibnetwork.com/objects/<uuid>?exp=...&tok=...",
"sprite_vtt_url": "https://cdn.zibnetwork.com/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.zibnetwork.com/stream/<vertical_file_id>/master.m3u8",
"poster_url": "https://cdn.zibnetwork.com/objects/<vertical_poster_id>"
}
}
}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).
//
// 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.zibnetwork.com/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, // stable key — NOT file_id
bucket,
// webhook_url and webhook_secret are optional here — if you've
// configured them once on your customer profile, ZiB uses those.
}),
});
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.zibnetwork.com/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.zibnetwork.com/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 on a 5-second flush timer and 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.
Inside an existing SDK instance
If you already construct ZiBStorage on the player page, opt in via the constructor option. Same effect; no second include needed.
const zib = new ZiBStorage({
accessKey: '...',
secretKey: '...',
telemetry: { enabled: true }, // <-- one extra option
});
// Optional — bring your own session ID if you already have one
// (e.g. tied to a playback session in your analytics).
new ZiBStorage({
accessKey: '...',
secretKey: '...',
telemetry: { enabled: true, 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.
Configure once via the Webhooks panel on the Storage page, or programmatically via PATCH /v1/api/customers/<customer_id>/webhook + POST /v1/api/customers/<customer_id>/webhook/secret. Every request without a per-request override inherits these defaults. 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.
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.
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"
"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.zibnetwork.com/objects/<poster_file_id>",
"sprite_url": "https://cdn.zibnetwork.com/objects/<first_sprite_sheet_file_id>",
"sprite_vtt_url": "https://cdn.zibnetwork.com/objects/<sprite_vtt_file_id>"
},
"aspect_variants": { // omitted if tenant didn't opt in; see "Aspect variants" above
"9:16": {
"hls_manifest_url": "https://cdn.zibnetwork.com/stream/<vertical_file_id>/master.m3u8",
"poster_url": "https://cdn.zibnetwork.com/objects/<vertical_poster_id>"
}
},
"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.zibnetwork.com/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.zibnetwork.com/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.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.zibnetwork.com/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.zibnetwork.com/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 queued pending_deletions row that the node consumes on its next heartbeat, so eventual cleanup is guaranteed even when a node is offline 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 both
cdn.zibnetwork.com/objects/{file_id}andcdn.zibnetwork.com/stream/{file_id}/. 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.zibnetwork.com/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 — today the UUID alone grants read, 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 presents the file_id, without credentials, because that is how a browser decrypts locally. What actually governs who can read an object is the next callout.file_id itself as a secret. Signed object URLs gate /objects/<uuid>, but they are not the only way bytes are reached: the SDK's direct-read path (fetchObject / fetchObjectStream) resolves read coordinates for a file_id without credentials, by design, so that a browser can pull from a holder and decrypt locally without the backend in the byte path. The practical consequence is what matters to you: anyone who learns a file_id can read that object, and turning on URL signing narrows that surface without closing it. So do not put file_ids in public listings, client-side bundles for content that is meant to be private, or anywhere you would not put a download link. If you need content that is private from people who might obtain the id, say so — that needs an access-control model we do not have today, not a URL change.cdn.zibnetwork.com/objects/<uuid> currently returns the file, so possessing the URL grants read access — including to source masters. Do not expose object URLs in public listings for files meant to be private. This path is moving behind a signed, expiring URL: store the file_id, not the URL, and resolve one at render time via Object URLs. Enforcement is currently off — that section has the current state, the test recipe, and how to opt in ahead of the global switch. Streaming URLs (/stream/{file_id}/master.m3u8) are not affected 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.zibnetwork.com/v1/api/my-jobs \
-H "Authorization: Bearer ${ACCESS_KEY}:${SECRET_KEY}"
# Filter:
curl -s "https://api.zibnetwork.com/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.zibnetwork.com/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.zibnetwork.com/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.zibnetwork.comwith 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.
const { encoding_id } = await zib.startEncoding(fileId, {
transcription: 'recommended', // 'fastest' | 'recommended' | 'accurate'
vision: 'standard' // 'standard' | 'hq'
});
// Poll until complete
const status = await zib.getEncodingStatus(encoding_id);
// status.srt_file_id → WebVTT file (auto-injected into HLS manifest)
// status.sidecar_file_id → ZibSidecar JSONStandalone compute jobs
Run transcription or vision on any already-stored video — no re-encoding needed.
// Standalone transcription
const { job_id } = await zib.submitTranscription(fileId, 'recommended');
// Standalone vision AI
const { job_id } = await zib.submitVisionAI(fileId, 'standard');
// Poll job status
const result = await zib.getComputeStatus(job_id);
// result.status → 'queued' | 'processing' | 'complete' | 'failed'
// result.srt_file_id → WebVTT file ID (when complete)
// result.sidecar_file_id → ZibSidecar file ID (when complete)Reading the ZibSidecar
The CDN decrypts the sidecar on the fly — fetch the CDN URL directly and parse as JSON.
// Get the sidecar CDN URL from job status
const status = await zib.getComputeStatus(job_id);
const sidecarUrl = zib.getCdnUrl(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 catalogue of titles available to you. Each entry is tagged live (currently published — may come and go as creators publish/unpublish) or licensed (held for you by an approved avail window — guaranteed present).
curl https://api.zibnetwork.com/v1/api/publisher/catalog \
-H "Authorization: Bearer $ZIB_PUBLISHER_KEY:$ZIB_PUBLISHER_SECRET"
# → { "count": N, "titles": [ {
# "id": "<title_id>", "file_id": "<file_id>", "title": "...", "creator": "...",
# "content_type": "movie", "availability": "live" | "licensed",
# "thumbnail_horizontal_url": "...", "thumbnail_vertical_url": "...",
# "window": { "start": "...", "end": "..." } | null,
# "playback": { "hls_url": "...", "poster_url": "...", "playability_url": "..." }
# }, ... ] }Filters: ?content_type=, ?creator=<slug>, ?availability=live|licensed, ?limit=.
3 · Request a licensing window (avails)
For content you need guaranteed for a period (e.g. a featured slate or a voting window), request an avail. The owning creator approves it; once approved and in-window the title is hard-locked into your catalogue — the creator can't pull it until the window ends. You can also do this visually in the dashboard (/publisher/catalog).
POST /v1/api/publisher/grants call; that endpoint does not exist and returns 404. The storage behind it is real (grants are recorded and honoured) and the creator-side controls ship in Studio, but the publisher-side request is not exposed over HTTP. Ask us to open a window and we will record it; we will document the endpoint here when it exists rather than before.4 · Mint a playback session (server-side, balance-gated)
When a viewer hits play, your backend mints a short-lived session. 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). Pass the returned session_token + the hls_url to the page.
// Your server — the secret key stays here.
const res = await fetch('https://api.zibnetwork.com/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({ file_id }), // file_id from the catalogue entry
});
if (res.status === 402) return renderTopUpPrompt();
const { session_token, hls_url, expires_at } = await res.json();
// send session_token + hls_url to the browser5 · 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.zibnetwork.com/v1/api/content/${titleId}/playability`)
.then(r => r.json());
if (!p.playable) showUnavailableInRegion(p.userCountry);6 · 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 4
fileId: "<file_id>",
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.7 · 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.zibnetwork.com/objects/{file_id}. These URLs are becoming 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.zibnetwork.com/v1/api/object-url?file_id=$FILE_ID"
{
"file_id": "...",
"url": "https://cdn.zibnetwork.com/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.zibnetwork.com/v1/api/object-urls"
{ "urls": { "<uuid-a>": "https://cdn.zibnetwork.com/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.
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.
What is not affected
HLS playback is unchanged — manifests, segments and keys are already signed, and players never touch this path. 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.
Testing before enforcement
Enforcement is currently off. It was enabled briefly on 2026-08-19 and switched back off within the hour: bare object URLs turned out to be embedded in live integrations built against the previous guidance on this page, which is our error, not theirs. It will not be enabled again without coordinating with every integration first.
A presented token is always judged, whether or not enforcement is on — only an absent one gets the lenient path. So a deliberately bad token gives you the real behaviour today:
# The &cb= is not decoration — see the warning below.
curl -i "https://cdn.zibnetwork.com/objects/$FILE_ID?exp=1&tok=bad&cb=$RANDOM" # 403
curl -i "https://cdn.zibnetwork.com/objects/$FILE_ID?cb=$RANDOM" # 200 — absent token is lenient?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 and manifest.mpd are safe to store and are not moving behind a token. 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: Direct S3-path uploads where the (bucket, key) already exists with different contents. The SDK's uploadDirect path handles supersede transparently; you only see 409 if you bypass the SDK.
What to do: Use `ZiBStorage.uploadDirect()` from the SDK. If you really need direct S3 PUT, 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.