VideohatiDocs
SDKs

Node.js SDK

Official @videohati/node SDK — create a client, upload video, create playback sessions, manage webhooks, and handle errors from a Node.js server.

@videohati/node is the official server SDK. It is ESM, ships zero runtime dependencies, and is typed against the public OpenAPI 3.1 spec at https://api.videohati.com/openapi.json. Version 1.0.0-rc.0.

Install

Node.js 18 or newer. The package is ESM only — use import, not require.

npm install @videohati/node

Create a client

Create one client and reuse it. The mode (live or test) always follows the key prefix — a vh_test_ key runs against test data.

import { Videohati } from "@videohati/node";

const videohati = new Videohati({
  apiKey: process.env.VIDEOHATI_API_KEY, // vh_live_... or vh_test_...
});

VideohatiOptions:

OptionDefaultWhat it does
apiKeyProject API key (vh_live_... or vh_test_...).
env"live" | "test" — asserts the key prefix matches.
baseUrlhttps://api.videohati.comAPI origin.
maxRetries2Retries for idempotent requests, 429s, and 5xxs.
timeoutMs30000Per-attempt timeout in milliseconds.
fetchglobal fetchCustom fetch implementation.

Never ship an API key to a browser. Anyone can read it from page source and use your account. For playback, create the session server-side and hand only the session token to the page.

Authentication

The client authenticates with a project API key sent as a bearer token. Use a vh_live_ key in production and a vh_test_ key against test data — the SDK derives the mode from the prefix. You create and manage keys in the dashboard.

Videos

The videos resource covers the whole lifecycle: create, upload, read, and manage thumbnails.

MethodSignatureWhat it does
createvideos.create({ originalFilename, idempotencyKey?, projectId? })Registers a video. Returns { videoId, state: "uploading" }. Step 1.
listvideos.list({ limit?, cursor?, state?, q?, tag?, projectId? })One page of videos: { data, nextCursor? }.
getvideos.get(videoId, { projectId? })Full detail, including upload and encoding projections.
updatevideos.update(videoId, { title?, description?, tags?, visibility?, projectId? })Partial metadata update; returns the updated video.
deletevideos.delete(videoId, { projectId? })Soft-deletes the video; aborts any active upload.
bulkvideos.bulk({ action, videoIds, visibility?, projectId? })Applies one action to 1–100 videos; per-id results.
setThumbnailvideos.setThumbnail(videoId, { data, contentType, projectId? })Sets a custom thumbnail (JPEG/PNG/WebP, at most 5 MB).
resetThumbnailvideos.resetThumbnail(videoId, { projectId? })Reverts to the automatic poster frame.
startUploadvideos.startUpload(videoId, { sizeBytes, contentType, checksumSha256?, idempotencyKey?, projectId? })Opens a multipart upload; returns part size, part count, and the part URL template. Step 2.
completeUploadvideos.completeUpload(videoId, { checksumSha256?, projectId? })Finalizes the upload. Step 3.
abortUploadvideos.abortUpload(videoId, { projectId? })Cancels an open upload.
uploadvideos.upload(file, options?)Runs create → start → part uploads → complete in one call.
waitForStatevideos.waitForState(videoId, { targetStates?, intervalMs?, timeoutMs?, projectId? })Polls until the video reaches a target state.

One-call upload

videos.upload(file, options) runs the full multipart flow in a single call. file is a path, a Buffer/Uint8Array, or a Blob/File.

const uploaded = await videohati.videos.upload("./lecture-01.mp4", {
  onProgress: (p) => console.log(`${p.uploadedBytes}/${p.totalBytes} bytes`),
});

// Poll until the video is playable end to end:
const video = await videohati.videos.waitForState(uploaded.videoId);
console.log(video.state); // "ready"

UploadOptions:

OptionDefaultWhat it does
projectIdIgnored under API-key auth (the key is already project-scoped).
filenamefile basename, else upload.binStored original filename.
contentTypeinferred from extensionMIME type sent to the server.
checksumSha256Lowercase hex SHA-256 of the whole file; the server verifies it.
idempotencyKeyStable identity for this attempt; makes a retry return the same video and the same upload session. See below.
onProgress(progress) => void with uploadedBytes, totalBytes, partsCompleted, totalParts.
partConcurrency4Parts uploaded in parallel.
maxPartAttempts3Attempts per part.
videoIdReuse an already-created video instead of creating one.
signalAbortSignal to cancel the upload.
fetchglobal fetchCustom fetch for the part uploads.

waitForState defaults to targetStates: ["ready", "failed"], intervalMs: 3000, and timeoutMs: 900000 (15 minutes); it rejects with a VideohatiError on timeout.

What a retry does and does not do

Pass idempotencyKey and both writes that create server state — the video and the upload session — become safe to repeat. Call upload again after a failure and you get the same video id and the same upload session back, even when the first attempt's response was lost in transit after the server had already committed it. Without a key, that lost response is indistinguishable from a request that never arrived, and the retry creates a second video.

Three limits worth reading before you rely on it:

  • The key must survive whatever you are recovering from. Retry safety across a process restart holds only if the value itself comes back the same after the restart, so derive it from durable state — a queue-entry id, a database row id. A value from Math.random(), Date.now(), or a counter in memory gives you no protection at all, because the retry sends a different key.
  • Nothing about the byte transfer resumes. A retry re-sends every part from the beginning. The guarantee is about not duplicating the video, not about saving bandwidth.
  • One key belongs to one file. The SDK binds the key to the file's name and size (and to checksumSha256 when you pass it), so reusing a key for a different file starts a genuinely new upload instead of returning the first file's video. Even so, prefer a fresh key per file.
// The key comes from the job row, so a crash and restart reuses it.
const uploaded = await videohati.videos.upload(job.path, {
  idempotencyKey: job.id,
  checksumSha256: job.sha256,
});

Retrying a create on its own

videos.create takes the same idempotencyKey, if you drive the three upload steps yourself. Matching is first-write-wins on (project, idempotencyKey) and the key is the whole identity — originalFilename is never part of it. A repeat of a committed call returns the first call's video, in whatever state it has since reached.

Reuse one key with a different file and you get the first call's video back, under its first filename; your second file is never uploaded, and the result carries no filename to reveal the substitution. Mint a fresh key per file — a UUID is the intended shape — including when re-uploading a file you sent before.

const { videoId } = await videohati.videos.create({
  originalFilename: "lecture-01.mp4",
  idempotencyKey: crypto.randomUUID(), // stored with your job, not regenerated
});

Search and filter

q is a case-insensitive literal substring match over title and originalFilename — it is not a fuzzy or full-text search, and it does not match description or tags. tag returns only videos carrying exactly that tag. Combine either with state and paginate with cursor.

const { data, nextCursor } = await videohati.videos.list({
  q: "lecture 01",
  tag: "arabic-101",
  state: "ready",
  limit: 50,
});

Update metadata

videos.update is a partial update: send any subset of title, description, tags, and visibility, and at least one of them.

FieldWhat it does
titleRenames the video. originalFilename never changes.
descriptionFree text. description: null clears it.
tagsReplaces the whole list (at most 20 tags).
visibility"private", "unlisted", or "public". unlisted/public enable the anonymous embed surface — hosted watch page, iframe, oEmbed.
const video = await videohati.videos.update(videoId, {
  title: "Lecture 01 — Introduction",
  tags: ["arabic-101", "semester-1"],
  visibility: "unlisted",
  description: null, // clears it
});

Bulk actions

videos.bulk applies one action to 1–100 video ids: { action: "delete" } or { action: "set_visibility", visibility }. delete behaves exactly like videos.delete per id.

The call resolves 200 even when some ids fail, so inspect each entry's ok flag — results has one entry per unique requested id, in request order, and a failed entry carries error.code and error.message.

const { results } = await videohati.videos.bulk({
  action: "set_visibility",
  visibility: "private",
  videoIds: [videoId, otherVideoId],
});
for (const result of results) {
  if (!result.ok) console.error(result.videoId, result.error.code);
}

Read and delete

const { data } = await videohati.videos.list({ state: "ready" });

const detail = await videohati.videos.get(videoId);
console.log(detail.state); // uploading | uploaded | encoding | ready | failed

await videohati.videos.delete(videoId); // soft delete; aborts any active upload

Playback

Create the session on your server and hand only sessionToken to the page (embed it with @videohati/player or @videohati/react).

MethodSignatureWhat it does
createSessionplayback.createSession({ videoId, ttlSeconds?, referrerAllowlist?, watermarkText?, viewerId?, projectId? })Creates a session server-side; returns the sessionToken for the player.

Your app calls only createSession; the player performs the other playback calls itself with the session token.

createSession parameters:

ParameterDefaultWhat it does
videoIdrequiredThe video to play.
ttlSecondsserver defaultSession lifetime.
referrerAllowlistRestrict playback to these referrers.
watermarkTextWatermark opt-in: text drawn onto the video. Also keys the per-viewer concurrency cap when no viewerId is given.
viewerIdYour own end-user identifier. Keys the per-viewer concurrency cap without drawing anything on the video.
projectIdIgnored under API-key auth (the key is already project-scoped).
const session = await videohati.playback.createSession({
  videoId,
  watermarkText: "Ahmed K.",
});
console.log(session.sessionToken);

Per-viewer concurrency without a watermark

The concurrent-session cap is counted per viewer, and viewer identity is resolved in this order: viewerId, then watermarkText, then a hash of the client IP. Without either field, every end-user behind one API key falls back to the IP hash — which collapses a whole shared network into one shared cap.

Pass viewerId to fix that while keeping playback clean: it identifies the viewer for the cap only. It is never stored — only its hash reaches the session — and it renders nothing. The watermark appears if and only if you set watermarkText, so viewerId on its own never turns one on.

// Independent caps per end-user, no overlay on the video.
const session = await videohati.playback.createSession({
  videoId,
  viewerId: user.id,
});

// Both, when you want an identified viewer AND a visible watermark.
const identified = await videohati.playback.createSession({
  videoId,
  viewerId: user.id,
  watermarkText: user.name,
});

Webhooks

Register endpoints, inspect deliveries, and verify signatures. Full flow in the webhooks guide.

MethodSignatureWhat it does
registerwebhooks.register(projectId, { url, description? })Registers an endpoint. secret is returned once.
listwebhooks.list(projectId)Lists endpoints.
getwebhooks.get(projectId, id)One endpoint.
updatewebhooks.update(projectId, id, { url?, description?, enabled? })Updates an endpoint.
deletewebhooks.delete(projectId, id)Soft-deletes an endpoint.
rotateSecretwebhooks.rotateSecret(projectId, id)Mints a fresh secret (returned once).
reopenCircuitwebhooks.reopenCircuit(projectId, id)Returns a tripped endpoint to service.
sendTestwebhooks.sendTest(projectId, id)Queues a synthetic video.encoding.ready delivery.
listDeliverieswebhooks.listDeliveries(projectId, id, { limit?, cursor?, state?, eventType? })One page of delivery attempts.
verifywebhooks.verify({ payload, header, secret, toleranceSeconds? })Verifies a signature and returns the parsed envelope.

Verify a signature

verifyWebhookSignature is also exported standalone. Pass the raw request body — not re-serialized JSON.

import { verifyWebhookSignature, SIGNATURE_HEADER } from "@videohati/node";

app.post("/webhooks/videohati", (req, res) => {
  const { valid, event } = verifyWebhookSignature({
    payload: req.rawBody, // string or Uint8Array, exactly as received
    header: req.headers[SIGNATURE_HEADER], // "videohati-signature"
    secret: process.env.VIDEOHATI_WEBHOOK_SECRET,
  });
  if (!valid) return res.status(400).end();
  if (event?.type === "video.encoding.ready") {
    // The video is ready — create a playback session, notify a user, etc.
  }
  res.status(200).end();
});

The header is Videohati-Signature: t=<unix_ts>,v1=<hex_sha256> (HMAC-SHA256 over <unix_ts>.<raw_body>); verification is constant-time and rejects timestamps more than 300 seconds old (tune with toleranceSeconds).

Projects and API keys

You create and manage projects and API keys in the dashboard, not through the SDK. See Concepts for how accounts, projects, and keys relate.

Errors

Every non-2xx response throws a typed subclass of VideohatiError, which carries status and code (the API's machine code, for example video_not_found). Errors never contain your API key.

ClassStatusWhen
VideohatiConnectionErrorThe request never reached the API (DNS, TLS, socket).
VideohatiTimeoutErrorThe request was aborted after timeoutMs. Subclass of the connection error.
VideohatiValidationError400The body or query failed validation.
VideohatiAuthenticationError401Missing or invalid credential.
VideohatiPermissionError403Valid credential, insufficient permission.
VideohatiNotFoundError404Resource missing or not visible to this caller.
VideohatiConflictError409Conflicts with the resource's current state.
VideohatiRateLimitError429Rate limit exceeded; read retryAfterSeconds.
VideohatiServerError5xxThe API failed to complete the request.
import {
  VideohatiError,
  VideohatiNotFoundError,
  VideohatiRateLimitError,
} from "@videohati/node";

try {
  await videohati.videos.get(videoId);
} catch (error) {
  if (error instanceof VideohatiNotFoundError) {
    // 404 — error.code === "video_not_found"
  } else if (error instanceof VideohatiRateLimitError) {
    await sleep((error.retryAfterSeconds ?? 1) * 1000);
  } else if (error instanceof VideohatiError) {
    console.error(error.status, error.code, error.message);
  }
}

GET and DELETE requests, 429s, and 5xxs retry with exponential backoff capped at 8 seconds — 2 retries by default. A 429 honors the Retry-After header. Tune the behavior with maxRetries and timeoutMs.