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/nodeCreate 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:
| Option | Default | What it does |
|---|---|---|
apiKey | — | Project API key (vh_live_... or vh_test_...). |
env | — | "live" | "test" — asserts the key prefix matches. |
baseUrl | https://api.videohati.com | API origin. |
maxRetries | 2 | Retries for idempotent requests, 429s, and 5xxs. |
timeoutMs | 30000 | Per-attempt timeout in milliseconds. |
fetch | global fetch | Custom 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.
| Method | Signature | What it does |
|---|---|---|
create | videos.create({ originalFilename, idempotencyKey?, projectId? }) | Registers a video. Returns { videoId, state: "uploading" }. Step 1. |
list | videos.list({ limit?, cursor?, state?, q?, tag?, projectId? }) | One page of videos: { data, nextCursor? }. |
get | videos.get(videoId, { projectId? }) | Full detail, including upload and encoding projections. |
update | videos.update(videoId, { title?, description?, tags?, visibility?, projectId? }) | Partial metadata update; returns the updated video. |
delete | videos.delete(videoId, { projectId? }) | Soft-deletes the video; aborts any active upload. |
bulk | videos.bulk({ action, videoIds, visibility?, projectId? }) | Applies one action to 1–100 videos; per-id results. |
setThumbnail | videos.setThumbnail(videoId, { data, contentType, projectId? }) | Sets a custom thumbnail (JPEG/PNG/WebP, at most 5 MB). |
resetThumbnail | videos.resetThumbnail(videoId, { projectId? }) | Reverts to the automatic poster frame. |
startUpload | videos.startUpload(videoId, { sizeBytes, contentType, checksumSha256?, idempotencyKey?, projectId? }) | Opens a multipart upload; returns part size, part count, and the part URL template. Step 2. |
completeUpload | videos.completeUpload(videoId, { checksumSha256?, projectId? }) | Finalizes the upload. Step 3. |
abortUpload | videos.abortUpload(videoId, { projectId? }) | Cancels an open upload. |
upload | videos.upload(file, options?) | Runs create → start → part uploads → complete in one call. |
waitForState | videos.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:
| Option | Default | What it does |
|---|---|---|
projectId | — | Ignored under API-key auth (the key is already project-scoped). |
filename | file basename, else upload.bin | Stored original filename. |
contentType | inferred from extension | MIME type sent to the server. |
checksumSha256 | — | Lowercase hex SHA-256 of the whole file; the server verifies it. |
idempotencyKey | — | Stable 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. |
partConcurrency | 4 | Parts uploaded in parallel. |
maxPartAttempts | 3 | Attempts per part. |
videoId | — | Reuse an already-created video instead of creating one. |
signal | — | AbortSignal to cancel the upload. |
fetch | global fetch | Custom 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
checksumSha256when 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.
| Field | What it does |
|---|---|
title | Renames the video. originalFilename never changes. |
description | Free text. description: null clears it. |
tags | Replaces 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 uploadPlayback
Create the session on your server and hand only sessionToken to the page
(embed it with @videohati/player or @videohati/react).
| Method | Signature | What it does |
|---|---|---|
createSession | playback.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:
| Parameter | Default | What it does |
|---|---|---|
videoId | required | The video to play. |
ttlSeconds | server default | Session lifetime. |
referrerAllowlist | — | Restrict playback to these referrers. |
watermarkText | — | Watermark opt-in: text drawn onto the video. Also keys the per-viewer concurrency cap when no viewerId is given. |
viewerId | — | Your own end-user identifier. Keys the per-viewer concurrency cap without drawing anything on the video. |
projectId | — | Ignored 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.
| Method | Signature | What it does |
|---|---|---|
register | webhooks.register(projectId, { url, description? }) | Registers an endpoint. secret is returned once. |
list | webhooks.list(projectId) | Lists endpoints. |
get | webhooks.get(projectId, id) | One endpoint. |
update | webhooks.update(projectId, id, { url?, description?, enabled? }) | Updates an endpoint. |
delete | webhooks.delete(projectId, id) | Soft-deletes an endpoint. |
rotateSecret | webhooks.rotateSecret(projectId, id) | Mints a fresh secret (returned once). |
reopenCircuit | webhooks.reopenCircuit(projectId, id) | Returns a tripped endpoint to service. |
sendTest | webhooks.sendTest(projectId, id) | Queues a synthetic video.encoding.ready delivery. |
listDeliveries | webhooks.listDeliveries(projectId, id, { limit?, cursor?, state?, eventType? }) | One page of delivery attempts. |
verify | webhooks.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.
| Class | Status | When |
|---|---|---|
VideohatiConnectionError | — | The request never reached the API (DNS, TLS, socket). |
VideohatiTimeoutError | — | The request was aborted after timeoutMs. Subclass of the connection error. |
VideohatiValidationError | 400 | The body or query failed validation. |
VideohatiAuthenticationError | 401 | Missing or invalid credential. |
VideohatiPermissionError | 403 | Valid credential, insufficient permission. |
VideohatiNotFoundError | 404 | Resource missing or not visible to this caller. |
VideohatiConflictError | 409 | Conflicts with the resource's current state. |
VideohatiRateLimitError | 429 | Rate limit exceeded; read retryAfterSeconds. |
VideohatiServerError | 5xx | The 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.