# Node.js SDK

`@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`.

```bash
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.

```ts
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](https://app.videohati.com).

## Videos

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

| Method | Signature | What it does |
| --- | --- | --- |
| `create` | `videos.create({ originalFilename, projectId? })` | Registers a video. Returns `{ videoId, state: "uploading" }`. Step 1. |
| `list` | `videos.list({ limit?, cursor?, state?, projectId? })` | One page of videos: `{ data, nextCursor? }`. |
| `get` | `videos.get(videoId, { projectId? })` | Full detail, including upload and encoding projections. |
| `delete` | `videos.delete(videoId, { projectId? })` | Soft-deletes the video; aborts any active upload. |
| `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`.

```ts
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` | — | Makes the upload resume-safe across restarts. |
| `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.

### Read and delete

```ts
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`).

| Method | Signature | What it does |
| --- | --- | --- |
| `createSession` | `playback.createSession({ videoId, ttlSeconds?, referrerAllowlist?, viewerIdFromRequest?, viewerDisplayText?, 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. |
| `viewerIdFromRequest` | — | Opaque viewer identifier for analytics and forensics. |
| `viewerDisplayText` | — | Text drawn into the on-video watermark. |
| `projectId` | — | Ignored under API-key auth (the key is already project-scoped). |

```ts
const session = await videohati.playback.createSession({
  videoId,
  viewerDisplayText: "Ahmed K.",
});
console.log(session.sessionToken);
```

## Webhooks

Register endpoints, inspect deliveries, and verify signatures. Full flow in the
[webhooks guide](/docs/guides/webhooks).

| 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.

```ts
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](https://app.videohati.com), not through the SDK. See
[Concepts](/docs/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. |

```ts
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`.
