# What is Videohati URL: https://docs.videohati.com/md/en/index.md Page: https://docs.videohati.com/en/docs Videohati is a home for your videos. You upload a file, Videohati prepares it so it plays smoothly on any phone, laptop, or connection, and then you show it on your own website or app behind a player you control. You keep your audience and your branding; Videohati handles the storage, the processing, and the delivery. Everything you can do by hand in the dashboard, you can also do from your own code through one API and a set of official libraries. This documentation covers both paths. ## Who it's for Videohati is built for teams that put video in front of an audience and care how it plays: - **Course and learning platforms** that stream lessons to students and want to know what was watched. - **Media and publishing teams** that need reliable playback without running their own video servers. - **Developers** who want a clean API, real SDKs, and a player they can drop into a web page. It is Arabic-first by design. The player controls, this documentation, and the dashboard are all available in Arabic and English, with full right-to-left support out of the box. ## Find your path - Quickstart: From an account to your first played video, step by step. - How it works: The plain-language tour: upload, processing, playback, and protection. - SDKs: Official libraries for Node.js, React, and Laravel (PHP). - API reference: Every endpoint, with a live test-mode playground. If you are not sure where to begin, read [How it works](/docs/how-it-works) first, then follow the [Quickstart](/docs/quickstart). **Not a developer?** You do not need to write code to understand Videohati or to upload and share a video. Start with [How it works](/docs/how-it-works) — it explains the whole journey in plain words, with no code. ## A few words you will see often - **Project** — a workspace that holds a group of videos and its own keys. Most teams use one project per site or app. - **API key** — a secret your code uses to prove it may act on a project. Test keys start with `vh_test_`; live keys start with `vh_live_`. - **Playback session** — the short-lived permission slip your site creates so a specific viewer can watch a specific video. Each of these is defined more fully in [Concepts](/docs/concepts). --- # How it works URL: https://docs.videohati.com/md/en/how-it-works.md Page: https://docs.videohati.com/en/docs/how-it-works This page explains what Videohati does, in plain words and with no code. If you only read one page before deciding whether Videohati fits your team, read this one. When you are ready to try it yourself, the [Quickstart](/docs/quickstart) walks through the same journey with real commands. ## From file to player A video goes through four stages between the moment you upload it and the moment someone watches it. You do not manage the stages yourself — Videohati moves the video through them and tells you when it is ready. 1. **You upload the video.** You can do this by hand in the dashboard, or from your own app if you have built Videohati into it. Large files are sent in small pieces, so an interrupted upload can pick up where it left off instead of starting over. 2. **Videohati processes the video.** A single source file rarely plays well for everyone: some viewers are on fast connections, others on slow ones. Videohati prepares several versions of your video at different quality levels so there is always one that fits the viewer's screen and connection. 3. **The video becomes ready.** Once processing finishes, the video is marked **ready**, and Videohati can notify your app automatically so you know it is safe to publish. If something about the source file cannot be processed, the video is marked **failed** instead, with a reason. 4. **Viewers watch through the player.** You place the Videohati player on your page. As each viewer watches, the player picks the right quality for their connection and adjusts it on the fly, so the video keeps playing smoothly instead of pausing to buffer. ## Test mode and live mode Videohati keeps two separate worlds so you can build and experiment without touching anything real. Which world you are in is decided by the key your code uses, not by a separate switch. - **Test mode** uses keys that start with `vh_test_`. Test videos, uploads, and playback are completely separate from your real content. This is where you try things out. - **Live mode** uses keys that start with `vh_live_`. This is your real audience and your real videos. The two never mix, so a mistake in test mode can never affect what your viewers see. ## Protection, in plain words Videohati gives you several ways to keep control of who watches your videos and to discourage copying: - **Watch links expire.** When someone opens a video, they get a temporary, signed link rather than a permanent file address. The link stops working after a short window, so it cannot be shared and reused later. - **A viewer's name can be shown on the video.** You can overlay a viewer's email or ID on top of the picture while it plays. This does not stop a determined person, but it makes a leaked recording traceable back to the account it came from. - **Playback can be limited to your own site.** You can require that a video only plays when it is embedded on domains you approve, so others cannot lift your player onto their pages. An honest word on this: no system on the open web can make downloading or screen recording truly impossible. Anything a person can watch, a determined person can capture. Videohati's job is to raise the effort, close the easy paths, and leave a trail — to **deter and trace**, not to promise the impossible. ## Measure what's watched [#measure] Once your videos are live, the dashboard shows you how they are actually being watched: how many times each video was played, how much total time people spent watching, and where viewers tend to drop off. That last number is often the most useful — it shows the exact point where attention fades, so you can see which lessons or segments hold up and which need work. The same dashboard also shows your usage and billing, so the cost of storage and delivery is never a surprise. Ready to try it? Head to the [Quickstart](/docs/quickstart), or read [Concepts](/docs/concepts) for clear definitions of the terms above. --- # Quickstart URL: https://docs.videohati.com/md/en/quickstart.md Page: https://docs.videohati.com/en/docs/quickstart This guide takes you from nothing to a video playing on your own page. Every step runs in test mode, so you can follow along safely without touching real content. It should take about ten minutes. ## Prerequisites - A Videohati account. Sign up at the [dashboard](https://app.videohati.com) and confirm your email. New accounts go through a short review before they are approved. - One of the following to make requests: - **Node.js 18 or newer**, if you want to use the official SDK, or - **`curl`**, if you prefer to call the API directly from a terminal. ## 1. Create a project A **project** is a workspace that holds a group of videos and its own API keys. Most teams use one project per site or app. In the dashboard, create your first project — for example, `Course videos`. You will point your keys and uploads at this project from here on. ## Get an API key [#api-key] An **API key** is the secret your code uses to prove it may act on your project. Create one before you make any request. In your project, open **API keys** and create a key in **test mode**. The full key is shown only once, so copy it immediately and store it as a secret. - A test key looks like `vh_test_...` and works on test data — the same API, kept separate from your live content. - A live key looks like `vh_live_...` and acts on your real content. Use live keys only later, once you are ready. Keep every API key on your server. Never put a `vh_test_` or `vh_live_` key in a browser, a mobile app, or any code your users can read. For playback, your server creates a short-lived session and hands only that to the page. Export your test key so the examples below can read it: ```bash export VIDEOHATI_API_KEY="vh_test_your_key_here" ``` ## 2. Make your first request The quickest way to confirm your key works is to list the videos in your project. A brand-new project returns an empty list — that empty result is the success you are looking for. Every endpoint lives under `https://api.videohati.com/v1` and authenticates with your key sent as a bearer token. ```bash curl https://api.videohati.com/v1/videos \ -H "Authorization: Bearer $VIDEOHATI_API_KEY" ``` A successful response is `200` with a JSON body: ```json { "data": [], "nextCursor": null } ``` Install the official client: ```bash npm install @videohati/node ``` Then list your videos. The mode follows the key prefix, so a `vh_test_` key puts the client in test mode automatically: ```ts import { Videohati } from "@videohati/node"; const client = new Videohati({ apiKey: process.env.VIDEOHATI_API_KEY }); const { data } = await client.videos.list(); console.log(`This project has ${data.length} video(s).`); ``` ## 3. Upload your first video Uploading a video is a single call. `videos.upload()` creates the video, sends the file in small parts to the upload endpoint, and finishes the upload for you. The `onProgress` callback lets you follow along. ```ts const video = await client.videos.upload("./lecture-01.mp4", { onProgress: ({ partsCompleted, totalParts }) => { console.log(`Uploaded part ${partsCompleted}/${totalParts}`); }, }); console.log(video.videoId); // 01JZ9WV3N8GQ5T2M7K4C6XBARH ``` After the upload, Videohati processes the file into several quality levels. Wait until it is **ready** before you try to play it: ```ts const ready = await client.videos.waitForState(video.videoId, { targetStates: ["ready", "failed"], }); if (ready.state === "failed") { throw new Error("This video could not be processed."); } console.log("Video is ready to play."); ``` ## 4. Play it To show a video, your server first creates a **playback session** — a short-lived permission slip for one viewer to watch one video. Create it on the server and hand only the session token (never your API key) to the page. ```ts const session = await client.playback.createSession({ videoId: ready.id, viewerDisplayText: "student@example.com", // optional: shown over the video }); console.log(session.sessionToken); // pass this to the player ``` Then embed the player on your page. The framework-free player reads what it needs from `data-` attributes: ```html
``` That is the whole loop: upload, wait for ready, create a session, embed the player. Your first video is now playing. ## Where next - Guides: Task-focused walkthroughs: uploads, playback, webhooks, and more. - SDKs: Official libraries for Node.js, React, and Laravel (PHP). - API reference: Every endpoint, with a live test-mode playground. - AI assistants: Use Videohati docs from your AI coding tools. --- # Concepts URL: https://docs.videohati.com/md/en/concepts.md Page: https://docs.videohati.com/en/docs/concepts This page defines the handful of ideas that everything in Videohati is built on. Each one is explained in plain words first, followed by a short note on where it shows up in the API. You can read it top to bottom, or jump to the term you need. ## Account An **account** is your organization's identity on Videohati. It is what you sign in to, and it owns your billing and your projects. One account can hold many projects — for example, one per website or app you run. **In the dashboard:** you sign in to your account at [app.videohati.com](https://app.videohati.com), which owns your projects and billing. ## Project A **project** is a workspace inside your account that holds a group of videos along with its own API keys and settings. Keeping videos in separate projects keeps their keys, usage, and access separate too. Most teams use one project per site or app. **In the dashboard:** create and manage projects at [app.videohati.com](https://app.videohati.com). Every video and key belongs to exactly one project. ## API key An **API key** is the secret your code uses to prove it may act on a project. Because it is a secret, it belongs only on your server, never in a browser or a mobile app. Keys come in two modes and can carry different permissions: - **Test keys** start with `vh_test_` and act on test data, kept separate from your real content. **Live keys** start with `vh_live_` and act on your real content. The prefix alone decides the mode. - A key carries **read** access, **write** access, or both. Give each key only the access it needs. - A key's full value is **shown only once**, when you create it. Copy it then and store it safely. - If a key is ever exposed, **rotate** it: rotating issues a new secret and retires the old one, so the leaked value stops working. **In the dashboard:** create, rotate, and revoke keys at [app.videohati.com](https://app.videohati.com). ## Video and its states A **video** is a single piece of content you have uploaded. From upload to playable, a video moves through a series of states. In these docs we use **processing** as the friendly umbrella for the work that happens between a finished upload and a ready video, but the API reports finer-grained states so your app can react precisely. The states the API can report are: | State | What it means | | --- | --- | | `uploading` | The file is still being sent. | | `uploaded` | The file arrived and is waiting to be processed. | | `encoding` | Videohati is preparing the quality levels. | | `ready` | Processing finished; the video can be played. | | `failed` | The video could not be processed. | The path most videos follow is **uploading → uploaded → encoding → ready**. A video that cannot be processed ends at **failed** instead, with a reason attached. **In the API:** read a video's `state` with `videos.get`, or wait for it to reach `ready` with `videos.waitForState`. See the [Node SDK](/docs/sdks/node). ## Playback session A **playback session** is a short-lived permission slip that lets one viewer watch one video. Your server creates a session when a viewer is about to watch, and it hands the viewer a session token and a temporary link — never the API key. Sessions are short-lived on purpose. The link a viewer receives expires after a short window, so it cannot be copied and reused later or passed around to people who should not have access. When it expires, your app simply creates a new one. **In the API:** create a session with `playback.createSession` on your server; the player keeps it alive while the viewer watches. ## Webhook A **webhook** is a message Videohati sends to your server when something happens — for example, when a video finishes processing. Instead of your app repeatedly asking "is it ready yet?", Videohati tells you the moment it is. You register a URL, and Videohati posts a signed message to it for each event. The eight event types are: | Event | Sent when | | --- | --- | | `video.upload.completed` | An upload finished and the file was received. | | `video.encoding.ready` | A video finished processing and is ready to play. | | `video.encoding.failed` | A video could not be processed. | | `video.deleted` | A video was deleted. | | `playback.started` | A viewer began watching a video. | | `playback.completed` | A viewer reached the end of a video. | | `payment.succeeded` | A payment on the account went through. | | `payment.failed` | A payment on the account did not go through. | Every delivery is signed, so your server can confirm it genuinely came from Videohati before acting on it. **In the API:** register endpoints with `webhooks.register`, and confirm each message with `webhooks.verify` (or `verifyWebhookSignature`). See the [Node SDK](/docs/sdks/node) and [Laravel SDK](/docs/sdks/laravel). ## Watermark and viewer text A **watermark** overlays a viewer's identity — their email or an ID you choose — on top of the video while it plays. It does not stop someone from recording the screen, but it makes any leaked copy traceable back to the account it came from. This is a deterrent, not a lock. **In the API:** set the overlay text when you create a session, with the `viewerDisplayText` option on `playback.createSession`. --- # Guide: Upload and play your first video URL: https://docs.videohati.com/md/en/guides/upload-and-play.md Page: https://docs.videohati.com/en/docs/guides/upload-and-play This guide takes you from an empty project to a playable video using the official Node.js SDK. Every step runs against test mode, so you can follow along without touching live data. ## Before you start You need: - Node.js 18 or newer (the SDK uses the built-in `fetch` and `AbortSignal`). - A project test API key. Create one in the dashboard under **Project → API keys**. A test key starts with `vh_test_`. - A short video file to upload, for example `lecture-01.mp4`. Keep your API key on the server. It is project-scoped and grants write access. Never ship a `vh_live_` or `vh_test_` key to a browser. ## 1. Install the SDK ```bash npm install @videohati/node ``` ## 2. Initialize the client The mode follows the key prefix: a `vh_test_` key puts the client in test mode automatically. ```ts import { Videohati } from "@videohati/node"; const videohati = new Videohati({ apiKey: process.env.VIDEOHATI_API_KEY, // vh_test_... }); console.log(videohati.mode); // "test" ``` ## 3. Upload a file in chunks `videos.upload()` runs the full multipart flow in one call: it creates the video, opens an upload session, `PUT`s every part to the upload endpoint, and completes the upload. Pass a file path, a `Uint8Array`/`Buffer`, or a `Blob`/`File`. ```ts const uploaded = await videohati.videos.upload("./lecture-01.mp4", { onProgress: ({ partsCompleted, totalParts }) => { console.log(`Uploaded part ${partsCompleted}/${totalParts}`); }, }); console.log(uploaded.videoId); // 01JZ9WV3N8GQ5T2M7K4C6XBARH console.log(uploaded.state); // "uploaded" ``` Want manual control instead of the one-call helper? The same flow is available as `videos.create()`, `videos.startUpload()`, the per-part `PUT` requests, and `videos.completeUpload()`. ## 4. Wait until the video is ready After the upload completes, encoding runs in the background. Poll the video with `videos.waitForState()` until it reaches `ready` (or `failed`). ```ts const video = await videohati.videos.waitForState(uploaded.videoId, { targetStates: ["ready", "failed"], }); if (video.state === "failed") { throw new Error(`Encoding failed: ${video.errorMessage ?? video.errorCode}`); } console.log("Video is ready to stream."); ``` ## 5. Create a playback session A playback session mints a short-lived, signed HLS manifest URL plus a `sessionToken` for the player. Create it server-side and hand only the session — never the API key — to the page. ```ts const session = await videohati.playback.createSession({ videoId: video.id, viewerDisplayText: "student@example.com", // shown in the watermark }); console.log(session.manifestUrl); // signed master.m3u8 URL console.log(session.sessionToken); // pass this to the player ``` The response includes everything the player needs: | Field | Meaning | | --- | --- | | `manifestUrl` | Signed HLS master playlist URL. | | `sessionToken` | Ownership token for the embedded player. | | `heartbeatIntervalSeconds` | How often the player re-asserts the session. | | `expiresAt` | When the session and its signed URL expire. | ## 6. Embed the player Hand the `videoId`, `projectId`, and `sessionToken` to the web player. The framework-free bundle reads them from `data-` attributes: ```html
``` For a React app, see [Embed a Videohati player in a Next.js 15 app](/docs/guides/embed-nextjs). ## Full script ```ts import { Videohati } from "@videohati/node"; async function main() { const videohati = new Videohati({ apiKey: process.env.VIDEOHATI_API_KEY }); const uploaded = await videohati.videos.upload("./lecture-01.mp4", { onProgress: ({ partsCompleted, totalParts }) => console.log(`Uploaded part ${partsCompleted}/${totalParts}`), }); const video = await videohati.videos.waitForState(uploaded.videoId); if (video.state === "failed") { throw new Error(`Encoding failed: ${video.errorMessage ?? video.errorCode}`); } const session = await videohati.playback.createSession({ videoId: video.id, viewerDisplayText: "student@example.com", }); console.log("Manifest:", session.manifestUrl); console.log("Session token:", session.sessionToken); } main().catch((error) => { console.error(error); process.exit(1); }); ``` --- # Guide: Embed a player in a Next.js 15 app URL: https://docs.videohati.com/md/en/guides/embed-nextjs.md Page: https://docs.videohati.com/en/docs/guides/embed-nextjs This guide embeds the Videohati player in a Next.js 15 App Router project. The session is created on the server with the Node SDK; the browser only ever sees a short-lived `sessionToken`. ## Before you start You need: - A Next.js 15 app using the App Router. - A project API key in test mode (`vh_test_...`) on the server. - A video that has finished encoding. See [Upload and play your first video](/docs/guides/upload-and-play). ## 1. Install the SDKs The React SDK renders the player; the Node SDK creates the session on the server. ```bash npm install @videohati/react @videohati/node ``` ## 2. Create the playback session on the server In a Server Component (or a route handler / server action), mint a session with the Node SDK. Keep the API key server-side. ```tsx // app/watch/[videoId]/page.tsx import { Videohati } from "@videohati/node"; import { Watch } from "./watch"; export default async function WatchPage({ params, }: { params: Promise<{ videoId: string }>; }) { const { videoId } = await params; const videohati = new Videohati({ apiKey: process.env.VIDEOHATI_API_KEY }); const session = await videohati.playback.createSession({ videoId, viewerDisplayText: "student@example.com", }); return ( ); } ``` `playback.createSession()` must run on the server. Passing a `vh_live_` or `vh_test_` API key into a client component would expose it in the browser bundle. ## 3. Render the player in a client component `` is a client component. Pass the `videoId`, `projectId`, and the server-created `sessionToken`. ```tsx // app/watch/[videoId]/watch.tsx "use client"; import { VideohatiPlayer } from "@videohati/react"; export function Watch({ videoId, projectId, sessionToken, }: { videoId: string; projectId: string; sessionToken: string; }) { return ( console.log("player state:", state)} onError={(error) => console.error(error.code, error.message)} style={{ width: "100%", maxWidth: 960 }} /> ); } ``` The player handles HLS playback, the session heartbeat, and the watermark on its own — you do not call the playback endpoints again from the browser. ## 4. RTL and Arabic Set `lang="ar"` to render the player chrome in Arabic with a right-to-left layout. The player mirrors its own controls; you only need to make sure the surrounding page direction is correct: ```tsx ``` In a localized Next.js layout, set `dir="rtl"` on `` (or the player's container) for the Arabic locale so the page around the player mirrors too. ## 5. Optional: configure a shared base URL If you point at a staging API, wrap the part of your tree that uses the React hooks with `VideohatiProvider`: ```tsx "use client"; import { VideohatiProvider } from "@videohati/react"; export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` For client-side uploads from the dashboard session, the `useVideohatiUpload` hook drives the same chunked flow inside the browser. --- # Guide: Integrate Videohati into a Laravel 11 project URL: https://docs.videohati.com/md/en/guides/integrate-laravel.md Page: https://docs.videohati.com/en/docs/guides/integrate-laravel This guide wires the official Laravel SDK into a Laravel 11 app. You will upload a video from the server, create a playback session, and render the player with the bundled Blade directive. ## Before you start You need: - A Laravel 11 app running on PHP 8.2 or newer. - A project API key in test mode (`vh_test_...`). - Your project id (used by the player embed). ## 1. Install the package ```bash composer require videohati/laravel ``` The package auto-discovers its service provider and the `Videohati` facade — no manual registration is needed. ## 2. Configure your environment Add your credentials to `.env`: ```ini VIDEOHATI_API_KEY=vh_test_xxxxxxxxxxxxxxxxxxxxx VIDEOHATI_PROJECT_ID=01JZ9WV3N8GQ5T2M7K4C6XBARP ``` Optionally publish the config file to tune the base URL, player URL, or timeout: ```bash php artisan vendor:publish --tag=videohati-config ``` Keep `VIDEOHATI_API_KEY` out of version control. The key is project-scoped and grants write access. ## 3. Upload a video `videos()->upload()` runs the full multipart flow: create the video, open the upload session, `PUT` each part to the upload endpoint, and complete the upload. It returns the completion payload, including the `videoId`. ```php use Videohati\Laravel\Facades\Videohati; $result = Videohati::videos()->upload( storage_path('app/lectures/lecture-01.mp4'), onProgress: function (int $uploaded, int $total) { logger()->info("Uploaded {$uploaded} / {$total} bytes"); }, ); $videoId = $result['videoId']; ``` Prefer manual control? The same flow is available as `videos()->create()`, `videos()->startUpload()`, the per-part `PUT` requests, and `videos()->completeUpload()`. ## 4. Wait until the video is ready Encoding runs after the upload completes. Poll with `videos()->get()` and check the DTO's `state` until it is `ready`: ```php use Videohati\Laravel\Facades\Videohati; do { sleep(3); $video = Videohati::videos()->get($videoId); } while (!in_array($video->state, ['ready', 'failed'], true)); abort_if($video->state === 'failed', 422, 'Encoding failed'); ``` ## 5. Create a playback session Create the session on the server, then pass only the `sessionToken` to the view — never the API key. ```php use Videohati\Laravel\Facades\Videohati; $session = Videohati::playback()->createSession( videoId: $videoId, viewerDisplayText: auth()->user()->email, // shown in the watermark ); return view('watch', [ 'videoId' => $videoId, 'sessionToken' => $session->sessionToken, ]); ``` The returned `PlaybackSessionDto` exposes `sessionId`, `manifestUrl`, `sessionToken`, `watermarkToken`, `expiresAt`, and `heartbeatIntervalSeconds`. ## 6. Embed the player in a Blade view The package ships a `@videohatiPlayer` Blade directive that emits the player ` ``` Replace the placeholder in `data-videohati-session-token` with the token your server just created. The player refuses to start without one and shows a "Missing session token" message instead. ## The data attributes Set these on the mount element. Only the first three are required. | Attribute | Required | Values | What it does | | --- | --- | --- | --- | | `data-videohati-video-id` | Yes | `01JZ9WV3N8GQ5T2M7K4C6XBARH` | The video to play. | | `data-videohati-project-id` | Yes | `01JZ9WV3N8GQ5T2M7K4C6XBARP` | The project the video belongs to. | | `data-videohati-session-token` | Yes | token string | The short-lived token from your server. | | `data-videohati-autoplay` | No | `"true"` \| `"muted"` | Start on load. Most browsers only allow autoplay when muted. | | `data-videohati-width` | No | number | Player width in pixels. | | `data-videohati-height` | No | number | Player height in pixels. | | `data-videohati-lang` | No | `"ar"` \| `"en"` | Player interface language. Defaults to `ar`. | | `data-videohati-viewer-text` | No | any text | Text shown in the on-video watermark. | | `data-videohati-api-origin` | No | URL | Point the player at a non-production API, such as staging. | ## WordPress and other CMSs In WordPress, add a **Custom HTML** block to your post or page, then paste the mount element and the script tag from step 2. Any CMS that accepts raw HTML works the same way. The one part a CMS cannot do for you is mint the session token — that needs your API key, which must stay on a server. Add a small endpoint on your site that calls `playback.createSession` and returns the token, then have your page fetch it before mounting the player. To create sessions automatically as videos become ready, see the [webhooks guide](/docs/guides/webhooks). ## Content Security Policy If your site sends a Content Security Policy header, allow the two Videohati origins so the script can load and reach the API: ``` script-src https://cdn.videohati.com; connect-src https://api.videohati.com https://cdn.videohati.com; media-src blob:; ``` --- # Guide: Receive events with webhooks URL: https://docs.videohati.com/md/en/guides/webhooks.md Page: https://docs.videohati.com/en/docs/guides/webhooks A webhook is a message Videohati sends to your server when something happens — a video finishes processing, a viewer starts watching, a payment succeeds. You give Videohati a URL, and it posts a small JSON message there each time an event fires. This is the reliable way to know when a video is `ready` without polling for it. ## 1. Register an endpoint Tell Videohati where to send events. The `secret` in the response is shown once — store it now, because you need it to verify every future delivery. ```ts import { Videohati } from "@videohati/node"; const client = new Videohati({ apiKey: process.env.VIDEOHATI_API_KEY }); const endpoint = await client.webhooks.register(projectId, { url: "https://example.com/webhooks/videohati", }); console.log(endpoint.secret); // shown once — save it as VIDEOHATI_WEBHOOK_SECRET ``` Prefer clicking? You can register the same endpoint in the dashboard under your project's webhook settings. The secret is still shown only once. ## 2. The events Videohati sends these event types. Each delivery names its type in the `type` field. | Event | Fires when | | --- | --- | | `video.upload.completed` | A file finished uploading, before processing. | | `video.encoding.ready` | The video finished processing and is ready to play. | | `video.encoding.failed` | Processing failed for a video. | | `video.deleted` | A video was deleted. | | `playback.started` | A viewer started a playback session. | | `playback.completed` | A playback session ended. | | `payment.succeeded` | A payment went through. | | `payment.failed` | A payment did not go through. | ## 3. The delivery envelope Every delivery is a JSON body with the same shape. The `data` object holds the details for that event type. ```json { "id": "01JZ9WV3N8GQ5T2M7K4C6XBARE", "type": "video.encoding.ready", "created_at": "2026-07-05T12:00:00Z", "project_id": "01JZ9WV3N8GQ5T2M7K4C6XBARP", "data": { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH" } } ``` ## 4. Verify the signature Every delivery carries a `Videohati-Signature` header. Verify it with your endpoint secret before you trust the body. Verification needs the **raw** request body, exactly as received — not a re-serialized JSON object. Node.js, with Express: ```ts import express from "express"; import { verifyWebhookSignature, SIGNATURE_HEADER } from "@videohati/node"; const app = express(); app.post( "/webhooks/videohati", express.raw({ type: "application/json" }), // keep the raw body (req, res) => { const header = req.header(SIGNATURE_HEADER); const result = verifyWebhookSignature({ payload: req.body, // Buffer of the raw body header, secret: process.env.VIDEOHATI_WEBHOOK_SECRET, }); if (!result.valid) return res.status(400).end(); // result.event is the parsed envelope. if (result.event?.type === "video.encoding.ready") { // The video is ready — create a playback session, notify a user, etc. } res.status(200).end(); }, ); ``` The same check in Laravel, using the `SignatureVerifier`: ```php use Illuminate\Http\Request; use Videohati\Laravel\Webhooks\SignatureVerifier; Route::post('/webhooks/videohati', function (Request $request) { $event = SignatureVerifier::verifyAndParse( $request->getContent(), // the raw body $request->header(SignatureVerifier::HEADER), config('services.videohati.webhook_secret'), ); if ($event === null) { abort(400); } if ($event['type'] === 'video.encoding.ready') { // React to the ready video. } return response()->noContent(); // 204 }); ``` ## 5. Respond fast Return a `2xx` status within 10 seconds. Videohati treats a slow or non-`2xx` response as a failed delivery. If you have real work to do, acknowledge first, then run the work in the background. ## Retries and the circuit breaker When a delivery fails, Videohati retries it with a growing delay. If an endpoint keeps failing, its circuit opens and deliveries pause so a broken endpoint does not pile up. After you fix the endpoint, reopen it: ```ts await client.webhooks.reopenCircuit(projectId, endpoint.id); ``` ## Test and inspect Send a synthetic `video.encoding.ready` delivery to confirm your endpoint works: ```ts await client.webhooks.sendTest(projectId, endpoint.id); ``` Review what was sent and how each attempt went with the delivery log: ```ts const deliveries = await client.webhooks.listDeliveries(projectId, endpoint.id, { limit: 20, }); ``` --- # Guide: Protect your videos URL: https://docs.videohati.com/md/en/guides/protect-videos.md Page: https://docs.videohati.com/en/docs/guides/protect-videos Videohati gives you three controls to keep your videos from spreading where you do not want them. Each one is an option you set when you create a playback session, so it applies per view. Be honest with yourself about the ceiling first: no system can make downloading or screen recording impossible. What these controls do is deter casual copying and trace a leak back to a specific viewer. They raise the effort and remove the anonymity — they are a deterrent, not a lock. Every option below is set on `playback.createSession`, which you always call on your server. ## Short-lived sessions A playback session is a temporary permission to watch. It expires on its own, so a shared link stops working. Set `ttlSeconds` to control how long a session stays valid. ```ts const session = await client.playback.createSession({ videoId, ttlSeconds: 3600, // this session is valid for one hour }); ``` Keep the window as short as your use fits. A shorter session means a copied link or token is useless sooner. ## Lock playback to your domains `referrerAllowlist` limits playback to pages served from domains you name. If someone lifts your embed and pastes it on another site, it will not play there. ```ts const session = await client.playback.createSession({ videoId, referrerAllowlist: ["example.com", "app.example.com"], }); ``` List every domain that should host the player, including subdomains you use. ## Put the viewer on the video A visible watermark that shows who is watching is a strong deterrent: a viewer who might share a recording thinks twice when their own name is burned into it. It also lets you trace a leaked copy back to one viewer. - `viewerDisplayText` is the text drawn on top of the video — a name, an email, an order number. - `viewerIdFromRequest` is an identifier you attach to the session for tracing. It is recorded with the session but not shown on screen, so you can tie a session back to a viewer without putting a raw identifier on the video. ```ts const session = await client.playback.createSession({ videoId, viewerDisplayText: "student@example.com", // shown over the video viewerIdFromRequest: "user_01H8…", // recorded for tracing, not shown }); ``` The player keeps the watermark visible while the video plays. Together these three controls make casual copying harder and leaks traceable — which, short of a physical lock, is the honest goal. --- # SDKs overview URL: https://docs.videohati.com/md/en/sdks/index.md Page: https://docs.videohati.com/en/docs/sdks An SDK saves you from hand-writing HTTP calls. It gives you typed methods, retries, the multipart upload flow, and webhook verification in one package — so you write `videohati.videos.upload(file)` instead of stitching requests together yourself. Videohati publishes four official libraries. Three run on the server or in the browser; one is the player that renders the video itself. ## Which one do I need? | Package | Install | Use it for | Runs where | | --- | --- | --- | --- | | `@videohati/node` | `npm install @videohati/node` | Uploading video, creating playback sessions, managing webhooks | Node.js server | | `@videohati/react` | `npm install @videohati/react @videohati/player` | The player component plus upload and metadata hooks | Browser + React | | `videohati/laravel` | `composer require videohati/laravel` | The same server work from PHP, with a Blade embed directive | PHP server | | `@videohati/player` | `npm install @videohati/player` or the script tag | Rendering a protected video on any page | Any website | | REST API directly | — | Any language we don't ship an SDK for | Your server — see [/docs/api](/docs/api) | `@videohati/node`, `@videohati/react`, and `videohati/laravel` are at `1.0.0-rc.0`. `@videohati/player` is at `1.0.0`. ## The golden path Every integration follows the same rule: your server holds the API key; the browser only ever sees a short-lived session token. 1. On your **server**, use `@videohati/node` or `videohati/laravel` to upload a video and create a playback session. 2. Hand the session token — not the API key — to the page. 3. In the **browser**, `@videohati/player` (or `@videohati/react`) renders the video from that token. The API key never leaves the server, so no one can read it from page source and use your account. ## Next steps - Server work in Node.js: [Node.js SDK](/docs/sdks/node). - The player component and hooks in React: [React SDK](/docs/sdks/react). - Server work in PHP: [Laravel SDK](/docs/sdks/laravel). - The framework-free player: [Player](/docs/sdks/player). --- # Node.js SDK URL: https://docs.videohati.com/md/en/sdks/node.md Page: https://docs.videohati.com/en/docs/sdks/node `@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=,v1=` (HMAC-SHA256 over `.`); 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`. --- # React SDK URL: https://docs.videohati.com/md/en/sdks/react.md Page: https://docs.videohati.com/en/docs/sdks/react `@videohati/react` gives you a `` component that wraps [`@videohati/player`](/docs/sdks/player), plus hooks for browser uploads, video metadata, and webhook deliveries. Version `1.0.0-rc.0`. ## Install React 18 or newer. The player is a peer dependency, so install both. ```bash npm install @videohati/react @videohati/player ``` ## Provider Wrap your app once. `VideohatiProvider` shares the API origin — and, for prototypes, an optional API key — with the hooks. | Prop | Default | What it does | | --- | --- | --- | | `baseUrl` | `https://api.videohati.com` | API origin. | | `apiKey` | — | Optional API key for browser calls. Discouraged. | | `children` | — | Your app. | ```tsx import { VideohatiProvider } from "@videohati/react"; ; ``` `useVideohatiConfig()` returns the current `{ baseUrl, apiKey? }` if you need it. Never ship a live API key to the browser. Playback uses server-created session tokens. The `apiKey` prop is for prototypes with `vh_test_` keys only. ## Play a video `sessionToken` comes from a **server-side** `POST /v1/playback/sessions` — use `@videohati/node` in your route handler or server action. ```tsx "use client"; import { VideohatiPlayer } from "@videohati/react"; export function Lesson({ sessionToken }: { sessionToken: string }) { return ( console.log(state)} /> ); } ``` `VideohatiPlayerProps`: | Prop | Type | What it does | | --- | --- | --- | | `videoId` | `string` (required) | The video to play. | | `projectId` | `string` (required) | The project that owns the video. | | `sessionToken` | `string` (required) | Token from a server-side playback session. | | `autoplay` | `boolean \| "muted"` | `"muted"` autoplays muted; `true` tries unmuted; default `false`. | | `width` | `number` | Player width in pixels. | | `height` | `number` | Player height in pixels. | | `lang` | `"ar" \| "en"` | UI language. Defaults to Arabic. | | `viewerText` | `string` | Watermark text; falls back to the session's viewer text. | | `apiOrigin` | `string` | API origin override (staging, local overrides). | | `className` | `string` | Class on the container element. | | `style` | `CSSProperties` | Inline style on the container. | | `onStateChange` | `(state) => void` | Fires on every playback state change. | | `onError` | `({ code, message }) => void` | Fires on a playback error. | | `onTimeUpdate` | `({ currentTime, duration }) => void` | Fires as the playhead moves. | | `playerRef` | `(player \| null) => void` | Receives the underlying player instance — call `play`/`seek`/`setQuality` on it. | ## Upload from the browser `useVideohatiUpload({ projectId?, partConcurrency? })` runs the same multipart flow as the Node SDK (create → start → part uploads → complete), 3 parts concurrently by default. It returns: | Field | Type | What it is | | --- | --- | --- | | `upload` | `(file: File) => Promise` | Starts the upload. | | `progress` | `UploadProgress \| null` | Live byte and part counts. | | `state` | `"idle" \| "creating" \| "uploading" \| "completing" \| "done" \| "error"` | Current phase. | | `error` | `{ status, code, message } \| null` | The failure, if any. | | `video` | `UploadResult \| null` | The completed video, once `state` is `done`. | | `reset` | `() => void` | Returns the hook to `idle` (ignored while busy). | ```tsx "use client"; import { useVideohatiUpload } from "@videohati/react"; function Uploader({ projectId }: { projectId: string }) { const { upload, progress, state, error, video, reset } = useVideohatiUpload({ projectId, }); return (
e.preventDefault()}> e.target.files?.[0] && upload(e.target.files[0])} /> {progress && ( )} {state === "done" &&

Uploaded: {video?.videoId}

} {state === "error" &&

{error?.message}

} {(state === "done" || state === "error") && ( )} ); } ``` ## Read video metadata `useVideohatiVideo(videoId, { projectId?, pollIntervalMs? })` reads a video and keeps it fresh. ```tsx import { useVideohatiVideo } from "@videohati/react"; const { video, error, isLoading, refresh } = useVideohatiVideo(videoId, { projectId, }); ``` It is cached across mounts, revalidated on mount, and polled every 5 seconds while `video.state` is `uploading`, `uploaded`, or `encoding` — so a processing video updates on its own. Set `pollIntervalMs: 0` to disable polling. `clearVideohatiVideoCache()` resets the shared cache. ## Webhook deliveries `useWebhookDeliveries(projectId, endpointId, { state?, eventType?, limit? })` reads one page of delivery attempts for a webhook endpoint. ```tsx import { useWebhookDeliveries } from "@videohati/react"; const { deliveries, nextCursor, error, isLoading, refresh } = useWebhookDeliveries( projectId, endpointId, { limit: 20 }, ); ``` Same caching as `useVideohatiVideo`. `clearWebhookDeliveriesCache()` resets its cache. ## Next.js App Router The player and every hook are client-only — put them in a component with `"use client"`. Mint the session token on the server (a route handler or server action) with `@videohati/node`, then pass it down as a prop. The full pattern is in the [Next.js embed guide](/docs/guides/embed-nextjs). ## Error handling Hooks expose errors as `{ status, code, message }` — `code` is the API's machine code (for example `account_not_approved`, `video_not_found`). The player reports playback errors through `onError`. ## Verify webhooks Webhook signature verification is server-side — use `@videohati/node`'s `verifyWebhookSignature` in your route handler. See the [webhooks guide](/docs/guides/webhooks). --- # Laravel SDK URL: https://docs.videohati.com/md/en/sdks/laravel.md Page: https://docs.videohati.com/en/docs/sdks/laravel `videohati/laravel` is the official PHP SDK for Laravel. The service provider and `Videohati` facade auto-register, TLS certificate verification is always on, and exceptions never contain your key. PHP 8.2+, Laravel 10 or 11. Version `1.0.0-rc.0`. ## Install ```bash composer require videohati/laravel ``` Set credentials in `.env`: ```dotenv VIDEOHATI_API_KEY=vh_live_... VIDEOHATI_PROJECT_ID=01JZ9WV3N8GQ5T2M7K4C6XBARF ``` A `vh_test_...` key runs against test data; the mode always follows the key prefix. ## Configure Publish the config file to change defaults: ```bash php artisan vendor:publish --tag=videohati-config ``` `config/videohati.php` reads these environment variables: | Variable | Default | What it sets | | --- | --- | --- | | `VIDEOHATI_API_KEY` | — | Project API key. | | `VIDEOHATI_BASE_URL` | `https://api.videohati.com` | API origin. | | `VIDEOHATI_PROJECT_ID` | — | Default project for the `@videohatiPlayer` directive. | | `VIDEOHATI_WEBHOOK_SECRET` | — | Endpoint secret for `SignatureVerifier`. | | `VIDEOHATI_PLAYER_URL` | `https://cdn.videohati.com/player.js` | Player bundle URL for the directive. | | `VIDEOHATI_TIMEOUT` | `30` | HTTP timeout in seconds. | ## Facade and dependency injection Reach the client through the `Videohati` facade or by injecting `Videohati\Laravel\Client`. ```php use Videohati\Laravel\Facades\Videohati; $videos = Videohati::videos()->list(state: 'ready'); ``` ```php use Videohati\Laravel\Client; class LessonController { public function __construct(private readonly Client $videohati) {} public function show(string $videoId) { $video = $this->videohati->videos()->get($videoId); // ... } } ``` The client exposes `videos()`, `playback()`, `webhookEndpoints()`, and `webhooks()` (the signature verifier). ## Videos `videos()->list()` returns a Laravel `Collection`. | Method | Signature | What it does | | --- | --- | --- | | `list` | `list(?string $state, int $limit = 20, ?string $cursor)` | One page as `Collection`. | | `listPage` | `listPage(?string $state, int $limit = 20, ?string $cursor)` | The raw page array, including `nextCursor`. | | `get` | `get(string $videoId)` | One `VideoDto`. | | `create` | `create(string $originalFilename)` | Registers a video; returns its id. Step 1. | | `delete` | `delete(string $videoId)` | Soft-deletes; aborts any active upload. | | `startUpload` | `startUpload(string $videoId, int $sizeBytes, string $contentType, ?string $checksumSha256, ?string $idempotencyKey)` | Opens a multipart upload; returns an `UploadStartDto`. Step 2. | | `completeUpload` | `completeUpload(string $videoId, ?string $checksumSha256)` | Finalizes the upload. Step 3. | | `abortUpload` | `abortUpload(string $videoId)` | Cancels an open upload. | | `upload` | `upload(string $filePath, ?string $contentType, ?string $idempotencyKey, ?callable $onProgress)` | Runs the full flow in one call. | ```php use Videohati\Laravel\Facades\Videohati; $videos = Videohati::videos()->list(state: 'ready', limit: 20); $videos->each(fn ($video) => logger($video->id . ' ' . $video->state)); $video = Videohati::videos()->get($videoId); // VideoDto Videohati::videos()->delete($videoId); // soft delete ``` `upload($filePath)` runs `POST /v1/videos` → `POST .../upload/start` → sequential part uploads (3 attempts each; 5xx/429 retried, 4xx fail fast) → `POST .../upload/complete`. The progress callback receives bytes uploaded and the total. ```php $result = Videohati::videos()->upload( storage_path('app/lecture-01.mp4'), onProgress: fn (int $uploaded, int $total) => logger("{$uploaded}/{$total}"), ); ``` `VideoDto` carries `id`, `state`, `originalFilename`, `sizeBytes`, `contentType`, `checksumSha256`, `errorCode`, `errorMessage`, `readyAt`, `failedAt`, `createdAt`, `updatedAt`, and — on detail responses — `upload` and `encoding`. `UploadStartDto` adds `partUrl($partNumber)` to build each part's upload URL. ## Create a playback session Never ship an API key to a browser. Create the session server-side and emit only the session token into the page. `playback()->createSession()` returns a `PlaybackSessionDto` with `sessionId`, `manifestUrl`, `sessionToken`, `watermarkToken`, `expiresAt`, `heartbeatIntervalSeconds`, and `showFreeBadge`. ```php $session = Videohati::playback()->createSession( videoId: $video->id, viewerDisplayText: $user->name, ); ``` The full signature is `createSession(string $videoId, ?int $ttlSeconds, ?array $referrerAllowlist, ?string $viewerIdFromRequest, ?string $viewerDisplayText)`. ## Embed the player The `@videohatiPlayer` Blade directive emits the embed snippet — the player `
``` Without a session token the player refuses to instantiate and shows "Missing session token". | Attribute | What it sets | | --- | --- | | `data-videohati-video-id` | The video to play (required). | | `data-videohati-project-id` | The project that owns the video (required). | | `data-videohati-session-token` | The server-created session token (required). | | `data-videohati-autoplay` | `"muted"` autoplays muted; `"true"` tries unmuted; default off. | | `data-videohati-width` | Width in pixels. | | `data-videohati-height` | Height in pixels. | | `data-videohati-lang` | `"ar"` (default) or `"en"`. | | `data-videohati-viewer-text` | Text drawn into the watermark. | | `data-videohati-api-origin` | API origin override (staging, local overrides). | ## npm Install the package and create a player yourself when you need the instance — for example to call `play()` or subscribe to events. ```bash npm install @videohati/player ``` ```ts import { Videohati } from "@videohati/player"; const player = Videohati.create({ target: document.querySelector("#player")!, videoId: "01JZ9WV3N8GQ5T2M7K4C6XBARH", projectId: "01JZ9WV3N8GQ5T2M7K4C6XBARF", sessionToken, lang: "en", }); player.on("state", (state) => console.log(state)); player.play(); ``` `Videohati.create(options)` returns a `Player`. | Option | Type | What it does | | --- | --- | --- | | `target` | `HTMLElement \| string` (required) | Element or selector the player renders into. | | `videoId` | `string` (required) | The video to play. | | `projectId` | `string` (required) | The project that owns the video. | | `sessionToken` | `string` (required) | The server-created session token. | | `autoplay` | `boolean \| "muted"` | `"muted"` autoplays muted; `true` tries unmuted; default `false`. | | `width` | `number` | Width in pixels. | | `height` | `number` | Height in pixels. | | `lang` | `"ar" \| "en"` | UI language. Defaults to Arabic. | | `viewerText` | `string` | Watermark text; falls back to the session's viewer text. | | `apiOrigin` | `string` | API origin override. | ## Player API The instance exposes a small imperative API. | Method | What it does | | --- | --- | | `play()` | Starts playback. | | `pause()` | Pauses playback. | | `seek(seconds)` | Jumps to a time, clamped to the video length. | | `setQuality(name)` | Selects a rendition by name (for example `"720p"`) or `"auto"`. | | `setLanguage(lang)` | Switches the UI language at runtime (`"ar"` / `"en"`). | | `getState()` | Returns the current state. | | `on(event, listener)` | Subscribes to an event. | | `off(event, listener)` | Removes a listener. | | `destroy()` | Tears down the player and ends the session. | Call `destroy()` when you remove the player from the page — it ends the session and stops the heartbeat. The script embed does this for you on page hide. ## States `getState()` and the `state` event return one of: `idle` · `loading` · `playing` · `paused` · `ended` · `stopped` · `error`. `stopped` is a terminal state the player enters when the session is revoked or the watermark is tampered with. ## Events Subscribe with `player.on(name, listener)`. | Event | Payload | Fires when | | --- | --- | --- | | `state` | the new state | Playback state changed. | | `error` | `{ code, message }` | Playback failed or the session ended. | | `timeupdate` | `{ currentTime, duration }` | The playhead moved. | | `qualitychange` | `{ quality }` | The active rendition changed. | | `watermarkTampered` | — | The on-video watermark was tampered with. | | `captureBlocked` | — | An in-page capture attempt was blocked. | The last two fire when the on-video watermark is tampered with or a capture attempt is blocked — you can log these. ## Language and direction The interface text defaults to Arabic. Pass `lang: "en"` (or `data-videohati-lang="en"`) for English. The strings are the only thing language changes: the player chrome itself is always left-to-right, like every mainstream player, so it lays out correctly inside a right-to-left page. When the embed passes no `lang`, the player follows the project's **Player UI language** setting (dashboard → project → Player), including its "match the viewer's browser" option. ## Customize the player Every embed of a project follows the project's player settings — no embed-code change needed. Open **dashboard → project → Player** to set: - **UI language and font** — Arabic, English, or the viewer's browser language; a font from a self-hosted set (loaded from the Videohati CDN, never a third-party origin). - **Primary color** — the progress bar, big play button, and menu highlights. - **Controls** — show or hide each control (play/pause, ±10s, progress, time, volume, quality, settings, fullscreen). Picture-in-picture and AirPlay exist as opt-ins and stay **off** by default: pop-out and cast playback bypass the watermark and capture protections. - **Playback speeds** — the exact speed-menu entries (0.1×–16×, up to 12, 1.0× always included). - **Watchtime heatmap** — a public watch-density curve above the progress bar, shown once a video has at least 30 viewing sessions. - **Resumable position** (on by default), **compact controls**, and **custom player CSS** scoped to `.vh-player`. Settings apply to playback sessions created after the save; an embed that passes an explicit `lang` keeps it. ## Content Security Policy Allow the player's two origins. `connect-src` covers the API and playback traffic; `media-src blob:` covers the streamed media; `script-src` is needed only for the script embed. ``` connect-src https://api.videohati.com https://cdn.videohati.com; media-src blob:; script-src https://cdn.videohati.com; ``` --- # API reference URL: https://docs.videohati.com/md/en/api/index.md Page: https://docs.videohati.com/en/docs/api The Videohati REST API lives under `https://api.videohati.com/v1`. This reference covers what an API-key developer calls: videos, the multipart upload flow, playback session creation, and webhook endpoint management. The staging host is `https://api.staging.videohati.com`. ## Authentication Every call you make uses one credential: - **API key** — `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys are scoped to a single project and carry `read` and/or `write` scopes. You create and manage keys in the [dashboard](https://app.videohati.com). The player authenticates its own calls with the short-lived session token your server creates — you never call those endpoints yourself. The in-page playground sends live requests with a `vh_test_` key. It rejects `vh_live_` keys so you never run a production-mode request from the docs. Start from the [Quickstart](/docs/quickstart) to get a test key end to end, or pick a client under [SDKs](/docs/sdks) to see how each language handles this for you. ## Errors Every error uses one envelope: ```json { "error": { "code": "", "message": "" } } ``` See [Errors](/docs/api/errors) for the status-code map and the shared codes. ## Pagination List endpoints accept `limit` (1–100, default 25) and an opaque `cursor`. The response carries `nextCursor` when more pages exist. ## Rate limits Rate-limited operations return `429` with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `Retry-After` headers. The per-operation pages list the limit that applies. ## Webhooks Registered endpoints receive HMAC-signed event deliveries. See [Events](/docs/api/events) for the envelope, the event names, and how to verify a signature. --- # Errors URL: https://docs.videohati.com/md/en/api/errors.md Page: https://docs.videohati.com/en/docs/api/errors Every error response uses one envelope: ```json { "error": { "code": "", "message": "" } } ``` - `code` is a machine-readable `snake_case` string. Branch on this, not the HTTP status alone. - `message` is a human-readable English string for logs and developer display. Each operation page lists the exact `code` values that operation emits. ## Status codes | Status | Meaning | | --- | --- | | `400` | The request body or query failed validation. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. | | `404` | The resource does not exist or is not visible to the caller. | | `409` | The request conflicts with the resource's current state. | | `429` | Rate limit exceeded. | | `500` | The server failed to complete the request. | ## Shared codes These appear across many operations: | Code | Status | Meaning | | --- | --- | --- | | `invalid_body` | `400` | Request body failed validation. | | `invalid_input` | `400` | A field failed validation. | | `unauthorized` | `401` | Authentication required. | | `insufficient_scope` | `403` | The API key lacks the required scope. | | `not_found` | `404` | Resource not found or not visible. | | `already_revoked` | `409` | The target resource is already revoked. | | `rate_limit_exceeded` | `429` | Too many requests; see `Retry-After`. | | `internal_error` | `500` | Unexpected server error. | ## Rate limiting A `429` carries three headers: - `X-RateLimit-Limit` — the ceiling for the window. - `X-RateLimit-Remaining` — requests left in the window. - `Retry-After` — seconds to wait before retrying. Back off until `Retry-After` elapses, then retry the same request. --- # Events URL: https://docs.videohati.com/md/en/api/events.md Page: https://docs.videohati.com/en/docs/api/events Register an endpoint under [Webhooks](/docs/api/webhooks/webhook-endpoints-create) and Videohati sends an HTTP `POST` to it each time one of the MVP events fires. You receive these deliveries — you never call the delivery endpoint yourself. For the end-to-end setup — registering an endpoint, verifying signatures, and handling retries in your own server — see the [webhooks guide](/docs/guides/webhooks). ## Event names | Event | Fires when | | --- | --- | | `video.upload.completed` | A multipart upload finished and the source object landed in storage. | | `video.encoding.ready` | Encoding finished; the video can be played. | | `video.encoding.failed` | Encoding failed for the video. | | `video.deleted` | A video was soft-deleted. | | `playback.started` | A playback session began playing. | | `playback.completed` | A playback session reached the end of the video. | | `payment.succeeded` | A payment for the account succeeded. | | `payment.failed` | A payment for the account failed. | ## Envelope Every delivery body is a JSON object with this shape: ```json { "id": "01JZ9WV3N8GQ5T2M7K4C6XBARM", "type": "video.encoding.ready", "created_at": "2026-06-11T11:00:00.000Z", "project_id": "01JZ9WV3N8GQ5T2M7K4C6XBARF", "data": { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH" } } ``` - `id` — ULID of this event. Use it to deduplicate retries. - `type` — one of the event names above. - `created_at` — ISO-8601 timestamp. - `project_id` — the project the event belongs to. - `data` — event-specific payload. ## Signature verification Each delivery carries the header: ``` Videohati-Signature: t=,v1= ``` The HMAC-SHA256 covers the string `.` using the endpoint's signing secret (returned once at registration or rotation). To verify: 1. Read `t` and `v1` from the header. 2. Reject if `t` is too old (a 5-minute tolerance keeps replays out). 3. Compute `HMAC_SHA256(secret, t + "." + rawBody)` as lowercase hex. 4. Compare against `v1` with a constant-time comparison. The official SDKs ship a `webhooks.verify` helper that does all four steps. Always verify against the raw request body — not a re-serialized object. ## Acknowledging a delivery Return any `2xx` within 10 seconds to acknowledge. Non-`2xx` responses (or timeouts) are retried on the delivery schedule. --- # AI assistants URL: https://docs.videohati.com/md/en/ai/index.md Page: https://docs.videohati.com/en/docs/ai If you write Videohati code with an AI assistant, give it the facts up front. This page has two things for that: docs it can read as plain text, and a context block you paste into your assistant's rules file so it stops guessing method names and states. ## Machine-readable docs Every page here is also served as plain markdown, plus two index files that follow the [llms.txt](https://llmstxt.org) convention. Point a tool at these instead of scraping the rendered HTML: - **[/llms.txt](https://docs.videohati.com/llms.txt)** — one line per page: title, markdown URL, and a short description. The map an assistant reads first. - **[/llms-full.txt](https://docs.videohati.com/llms-full.txt)** — every page concatenated into a single text stream, for a tool that wants the whole set in one fetch. - **`/md/en/.md`** — any page as raw markdown. For example [/md/en/quickstart.md](https://docs.videohati.com/md/en/quickstart.md) or [/md/en/sdks/node.md](https://docs.videohati.com/md/en/sdks/node.md). Arabic pages are at `/md/ar/.md`. ## Teach your assistant Videohati The block below is a compact, code-oriented brief: authentication, the upload → wait → play path, per-stack entry points, the webhook events, error classes, and the pitfalls that trip up first attempts. Copy it, or download it straight into a rules file. ```bash curl -o CLAUDE.md https://docs.videohati.com/ai/videohati-context.md ``` ## Set up your tool Each assistant reads its project rules from a specific file. Drop the context block into the right one and it applies to every prompt in that project. Claude Code reads `CLAUDE.md` at the repository root and treats it as always-on project memory. Append the context block to it — this keeps any notes you already have and adds Videohati underneath. ```bash curl https://docs.videohati.com/ai/videohati-context.md >> CLAUDE.md ``` Codex reads `AGENTS.md` at the repository root. Append the context block so the Videohati facts load with every task in this project. ```bash curl https://docs.videohati.com/ai/videohati-context.md >> AGENTS.md ``` opencode also uses the `AGENTS.md` convention. The same one-liner works — if the file already exists, this adds Videohati at the end without disturbing the rest. ```bash curl https://docs.videohati.com/ai/videohati-context.md >> AGENTS.md ``` Cursor reads rule files from `.cursor/rules/`. Save the block as an `.mdc` rule, then add a two-line frontmatter header so Cursor pulls it in only when it is relevant rather than on every prompt. ```bash mkdir -p .cursor/rules curl -o .cursor/rules/videohati.mdc https://docs.videohati.com/ai/videohati-context.md ``` Add this frontmatter to the top of `.cursor/rules/videohati.mdc`: ```mdc --- description: Videohati video API — auth, upload, playback sessions, webhooks alwaysApply: false --- ``` Tools that browse documentation on their own pick up `/llms.txt` automatically — no setup needed. --- # Guides URL: https://docs.videohati.com/md/en/guides/index.md Page: https://docs.videohati.com/en/docs/guides Each guide below walks through one complete task, from an empty project to a working result. Pick the one that matches what you're building. - Upload and play your first video: Install the Node SDK, upload a file, and create a playback session end to end. - Embed a player in a Next.js app: Install the React SDK and render the player in a client component. - Integrate Videohati into Laravel: Install the Laravel package, upload a video, and embed the player in a Blade view. - Embed on any site: Play a video on a plain HTML page, WordPress, or any CMS with one script tag. - Receive events with webhooks: Register an endpoint, verify signatures, and react to Videohati events. - Protect your videos: Use short-lived sessions, a referrer allowlist, and a watermark to deter copying. --- # Guide: Stream security URL: https://docs.videohati.com/md/en/guides/stream-security.md Page: https://docs.videohati.com/en/docs/guides/stream-security Every project has a **Security** page in the dashboard with four controls that apply to the whole project — no code changes needed for the first three. They complement the per-session options described in [Protect your videos](/en/guides/protect-videos). The honest ceiling applies here too: none of this makes downloading or screen recording impossible. These controls decide **where** playback is allowed to start, and raise the effort for everyone else. ## Direct play The master switch for anonymous playback. While it is on (the default), any video you set to `public` or `unlisted` plays on its hosted watch page, in iframe embeds, and through oEmbed. Turn it off and the whole anonymous surface goes dark for this project — the hosted pages show "unavailable" and the embed mint refuses, exactly as if every video were private. Sessions minted by your server through `playback.createSession` keep working, so a members-only site loses nothing. ## Allowed and blocked domains Two lists of domains, checked against the page that embeds or links your video: - **Allowed domains** — when the list is non-empty, only these sites may play your videos. An empty list allows every site. - **Blocked domains** — always refused, checked before the allowlist. `*.example.com` matches every subdomain **and** `example.com` itself. You can paste a full URL — `https://app.example.com/courses` is stored as `app.example.com`. Two details worth knowing: - When an allowed list is set, requests that arrive with **no referring page at all** are refused too. Browsers always send a referrer for embedded players, so real viewers are unaffected; stripped-referrer scrapers are not real viewers. - Your own dashboard previews are exempt — you can always watch your own videos while logged in, whatever the lists say. The lists also become the default `referrerAllowlist` for sessions your server mints with an API key. Passing an explicit `referrerAllowlist` on `createSession` overrides the project list for that session; passing `[]` opts that session out entirely. ## Block direct URL file access When on, media requests that carry no `Referer` header at all are refused — video URLs pasted into a bare tab, download managers, and most scripted fetchers. Playback from a real page keeps working because pages always refer their own media requests. ## Embed view token authentication The strongest gate: when enabled, the embedded player only loads when the embed URL carries a token **your server** signed. Nobody can start playback from a URL you did not generate — even with the video ID in hand. The dashboard shows your project's **embed token key** (rotatable at any time). Sign like this: ``` token = hex( HMAC-SHA256( key, videoId + "|" + expires ) ) expires = unix seconds when the link stops working ``` Then append both to the hosted watch or embed URL: ``` https://cdn.videohati.com/embed/VIDEO_ID?token=TOKEN&expires=1900000000 ``` ### Node ```ts import { createHmac } from "node:crypto"; function signEmbedUrl(videoId: string, key: string, ttlSeconds = 3600): string { const expires = Math.floor(Date.now() / 1000) + ttlSeconds; const token = createHmac("sha256", key) .update(`${videoId}|${expires}`) .digest("hex"); return `https://cdn.videohati.com/embed/${videoId}?token=${token}&expires=${expires}`; } ``` ### PHP ```php function signEmbedUrl(string $videoId, string $key, int $ttlSeconds = 3600): string { $expires = time() + $ttlSeconds; $token = hash_hmac('sha256', $videoId . '|' . $expires, $key); return "https://cdn.videohati.com/embed/{$videoId}?token={$token}&expires={$expires}"; } ``` Rotate the key from the dashboard whenever you suspect it leaked: tokens signed with the old key stop verifying immediately, and every embed URL you generate after that must use the new key. ### What to combine | Goal | Controls | | --- | --- | | "Only my site can embed my videos" | Allowed domains | | "That one site keeps stealing embeds" | Blocked domains | | "No hotlinking the video files" | Block direct URL file access | | "Only logged-in members of my app" | Embed token authentication, short `expires` | | "Nothing public at all" | Direct play off + server-minted sessions | --- # Health probe URL: https://docs.videohati.com/md/en/api/operations/get-health.md Page: https://docs.videohati.com/en/docs/api/operations/get-health **GET /healthz** Liveness probe. Returns `200` with build metadata. No auth, no rate limit. ## Authentication - This operation needs no authentication. ## Parameters This operation takes no parameters. ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | Service is up. | ## Try it --- # This OpenAPI document URL: https://docs.videohati.com/md/en/api/operations/get-open-api-spec.md Page: https://docs.videohati.com/en/docs/api/operations/get-open-api-spec **GET /openapi.json** Returns this OpenAPI 3.1 document as JSON. No auth. Served with `Cache-Control: public, max-age=300`. ## Authentication - This operation needs no authentication. ## Parameters This operation takes no parameters. ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | The OpenAPI 3.1 document. | ## Try it --- # Create an anonymous playback session for a shared video URL: https://docs.videohati.com/md/en/api/playback/playback-embed-sessions-create.md Page: https://docs.videohati.com/en/docs/api/playback/playback-embed-sessions-create **POST /v1/playback/embed-sessions** Unauthenticated session mint for videos whose visibility is `public` or `unlisted` — called by the hosted watch/embed pages (and usable from any page) so viewers never need a customer server to mint tokens. Sessions carry every protection of the authenticated mint: signed segment URLs, the edge session mirror, the heartbeat-gated key, geo/ASN policy, and the account overdraft gate. The per-viewer concurrency cap keys on a hash of the viewer IP. Rate limit: 30 requests per 60 s per IP. Error codes: `invalid_body` (400), `video_not_found` (404), `video_not_playable` (409), `insufficient_balance` (402). ## Authentication - This operation needs no authentication. ## Parameters This operation takes no parameters. ## Request body _required_ **Content type:** `application/json` **Schema:** `EmbedSessionCreateRequest` ```json { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH" } ``` ## Responses | Status | Meaning | | --- | --- | | `201` | Playback session created. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `402` | The account's balance is below its plan overdraft limit. | | `404` | The resource does not exist or is not visible to this caller. | | `409` | The request conflicts with the resource's current state. The per-operation description lists the exact `error.code` values. | | `429` | Rate limit exceeded. | ## Example request ```json { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH" } ``` ## Try it --- # Public metadata for a shared video URL: https://docs.videohati.com/md/en/api/playback/playback-public-video-get.md Page: https://docs.videohati.com/en/docs/api/playback/playback-public-video-get **GET /v1/playback/public-videos/{videoId}** Unauthenticated metadata for a video whose visibility is `public` or `unlisted` — backs the hosted watch page and oEmbed unfurls. Videos that are `private`, deleted, or unknown all return the same 404, so the endpoint reveals nothing about unshared content. Rate limit: 120 requests per 60 s per IP. Error codes: `video_not_found` (404). ## Authentication - This operation needs no authentication. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `videoId` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | Public video metadata. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Create a playback session URL: https://docs.videohati.com/md/en/api/playback/playback-sessions-create.md Page: https://docs.videohati.com/en/docs/api/playback/playback-sessions-create **POST /v1/playback/sessions** Server-side call: never expose your API key to the browser. Returns a signed `manifestUrl`, a `sessionToken` for the player, and a `watermarkToken`. The video must have at least one playable rendition. Requires an API key with `read` scope. Rate limit: 30 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `video_not_found` (404), `video_not_playable` (409), `invalid_body` (400). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters This operation takes no parameters. ## Request body _required_ **Content type:** `application/json` **Schema:** `PlaybackSessionCreateRequest` ```json { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH", "ttlSeconds": 3600, "watermarkText": "Ahmed K." } ``` ## Responses | Status | Meaning | | --- | --- | | `201` | Playback session created. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `409` | The request conflicts with the resource's current state. The per-operation description lists the exact `error.code` values. | | `429` | Rate limit exceeded. | ## Example request ```json { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH", "ttlSeconds": 3600, "watermarkText": "Ahmed K." } ``` ## Try it --- # Create a video in `uploading` state URL: https://docs.videohati.com/md/en/api/videos/videos-create.md Page: https://docs.videohati.com/en/docs/api/videos/videos-create **POST /v1/videos** Step 1 of the upload flow. Requires an API key with `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `invalid_filename` (400). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters This operation takes no parameters. ## Request body _required_ **Content type:** `application/json` **Schema:** `VideoCreateRequest` ```json { "originalFilename": "lecture-01.mp4" } ``` ## Responses | Status | Meaning | | --- | --- | | `201` | Video created; continue with `POST /v1/videos/{id}/upload/start`. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Example request ```json { "originalFilename": "lecture-01.mp4" } ``` ## Try it --- # Soft-delete a video URL: https://docs.videohati.com/md/en/api/videos/videos-delete.md Page: https://docs.videohati.com/en/docs/api/videos/videos-delete **DELETE /v1/videos/{id}** Sets `deleted_at` and aborts any active upload session. Requires an API key with `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `204` | Video soft-deleted. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Get a video with upload and encoding detail URL: https://docs.videohati.com/md/en/api/videos/videos-get.md Page: https://docs.videohati.com/en/docs/api/videos/videos-get **GET /v1/videos/{id}** Returns the video plus its upload session and encoding progress. Requires an API key with `read` scope. Rate limit: 120 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `video_not_found` (404). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | The video with `upload` and `encoding` projections. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # List videos URL: https://docs.videohati.com/md/en/api/videos/videos-list.md Page: https://docs.videohati.com/en/docs/api/videos/videos-list **GET /v1/videos** Keyset pagination ordered by `id` descending, with an optional `state` filter. Requires an API key with `read` scope. Rate limit: 120 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `invalid_query` (400). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `limit` | query | integer | No | — | | `cursor` | query | string (Ulid) | No | ULID of the last item from the previous page. | | `state` | query | string (VideoState) | No | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | One page of videos. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Remove the custom thumbnail URL: https://docs.videohati.com/md/en/api/videos/videos-reset-thumbnail.md Page: https://docs.videohati.com/en/docs/api/videos/videos-reset-thumbnail **DELETE /v1/videos/{id}/thumbnail** Deletes the custom thumbnail and reverts to the encoder's automatic poster frame when one exists (`thumbnailUrl` is null otherwise). Requires an API key with `write` scope. Rate limit: 30 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `video_not_found` (404). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | The thumbnail after the reset (auto poster or null). | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Set a custom thumbnail URL: https://docs.videohati.com/md/en/api/videos/videos-set-thumbnail.md Page: https://docs.videohati.com/en/docs/api/videos/videos-set-thumbnail **PUT /v1/videos/{id}/thumbnail** Replaces the video's thumbnail with a custom image. The request body is the raw image bytes (JPEG, PNG, or WebP; at most 5 MB) and the `Content-Type` header must match the payload's actual format. Requires an API key with `write` scope. Rate limit: 30 requests per 60 s per caller. The custom image survives encode retries; delete it with `DELETE /v1/videos/{id}/thumbnail` to return to the automatic poster frame. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `invalid_image` (400), `invalid_body` (400), `not_found` (404), `video_not_found` (404). Unsupported content types return 415; oversize bodies return 413. ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body _required_ **Content type:** `image/jpeg` **Content type:** `image/png` **Content type:** `image/webp` ## Responses | Status | Meaning | | --- | --- | | `200` | The new public thumbnail URL. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Update a video's visibility URL: https://docs.videohati.com/md/en/api/videos/videos-update.md Page: https://docs.videohati.com/en/docs/api/videos/videos-update **PATCH /v1/videos/{id}** Sets the video's visibility. `private` (the default) keeps playback sessions mintable only through authenticated callers; `unlisted` and `public` additionally enable the anonymous embed surface — the hosted watch page, the copy-paste iframe, and oEmbed. `public` alone marks the hosted page indexable. Requires an API key with `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `video_not_found` (404), `invalid_body` (400). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body _required_ **Content type:** `application/json` **Schema:** `VideoUpdateRequest` ```json { "visibility": "unlisted" } ``` ## Responses | Status | Meaning | | --- | --- | | `200` | The updated video. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Example request ```json { "visibility": "unlisted" } ``` ## Try it --- # Abort a multipart upload URL: https://docs.videohati.com/md/en/api/videos/videos-upload-abort.md Page: https://docs.videohati.com/en/docs/api/videos/videos-upload-abort **POST /v1/videos/{id}/upload/abort** Aborts the multipart upload and marks the session `aborted`. Idempotent for already-aborted sessions. Requires an API key with `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `video_not_found` (404), `upload_already_completed` (409). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | Upload aborted. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `409` | The request conflicts with the resource's current state. The per-operation description lists the exact `error.code` values. | | `429` | Rate limit exceeded. | ## Try it --- # Complete a multipart upload URL: https://docs.videohati.com/md/en/api/videos/videos-upload-complete.md Page: https://docs.videohati.com/en/docs/api/videos/videos-upload-complete **POST /v1/videos/{id}/upload/complete** Step 3 of the upload flow. The server loads the part ledger written by the upload endpoint (a client `parts` array is rejected), completes the multipart upload, verifies the object, and transitions the video to `uploaded`. Requires an API key with `write` scope, or session-cookie auth plus `projectId`. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `video_not_found` (404), `invalid_body` (400), `invalid_checksum_sha256` (400), `parts_missing` (409), `checksum_mismatch` (400), `upload_already_completed` (409). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body _optional_ **Content type:** `application/json` **Schema:** `UploadCompleteRequest` ```json { "checksumSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" } ``` ## Responses | Status | Meaning | | --- | --- | | `200` | Upload completed; the video is `uploaded` and queued for encoding. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `409` | The request conflicts with the resource's current state. The per-operation description lists the exact `error.code` values. | | `429` | Rate limit exceeded. | ## Example request ```json { "checksumSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" } ``` ## Try it --- # Start (or resume) a multipart upload URL: https://docs.videohati.com/md/en/api/videos/videos-upload-start.md Page: https://docs.videohati.com/en/docs/api/videos/videos-upload-start **POST /v1/videos/{id}/upload/start** Step 2 of the upload flow. Declares the file size and content type and returns an `uploadUrlTemplate` — replace `{partNumber}` (1-based) and `PUT` each part of `partSize` bytes (the final part is allowed to be shorter) to the upload endpoint. Calling start again with the same `idempotencyKey` resumes the existing session. Requires an API key with `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `account_not_approved` (403), `invalid_project_id` (400), `not_found` (404), `video_not_found` (404), `invalid_size` (400), `invalid_checksum_sha256` (400), `file_too_large` (400), `upload_limit_exceeded` (429), `storage_quota_exceeded` (402 — Free-plan projects at/over their storage quota; delete videos or upgrade to continue uploading). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string (Ulid) | Yes | — | ## Request body _required_ **Content type:** `application/json` **Schema:** `UploadStartRequest` ```json { "sizeBytes": 10485760, "contentType": "video/mp4", "idempotencyKey": "lecture-01-v1" } ``` ## Responses | Status | Meaning | | --- | --- | | `201` | Upload session ready; `PUT` parts to `uploadUrlTemplate`. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Example request ```json { "sizeBytes": 10485760, "contentType": "video/mp4", "idempotencyKey": "lecture-01-v1" } ``` ## Try it --- # Register a webhook endpoint URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-create.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-create **POST /v1/projects/{projectId}/webhook-endpoints** The response contains `secret` exactly once; only an AES-256-GCM wrapped copy is stored. The URL must be `https://` and must not resolve to a private, loopback, link-local, CGNAT, or cloud-metadata address (SSRF defence). An API key needs the `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `not_found` (404), `invalid_url` (400), `invalid_body` (400), `endpoint_limit_reached` (409), `webhooks_unconfigured` (503). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | ## Request body _required_ **Content type:** `application/json` **Schema:** `WebhookEndpointCreateRequest` ```json { "url": "https://example.com/webhooks/videohati", "description": "Production receiver" } ``` ## Responses | Status | Meaning | | --- | --- | | `201` | Endpoint registered. Store `secret` now; it is never shown again. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `409` | The request conflicts with the resource's current state. The per-operation description lists the exact `error.code` values. | | `429` | Rate limit exceeded. | ## Example request ```json { "url": "https://example.com/webhooks/videohati", "description": "Production receiver" } ``` ## Try it --- # Delete a webhook endpoint URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-delete.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-delete **DELETE /v1/projects/{projectId}/webhook-endpoints/{id}** Soft delete; the endpoint stops receiving deliveries immediately. An API key needs the `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `endpoint_not_found` (404). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `204` | Endpoint soft-deleted. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Get a webhook endpoint URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-get.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-get **GET /v1/projects/{projectId}/webhook-endpoints/{id}** Never returns the secret. An API key needs `read` (or `write`). Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `endpoint_not_found` (404). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | The endpoint. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # List webhook endpoints URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-list.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-list **GET /v1/projects/{projectId}/webhook-endpoints** Never returns the secret. An API key needs `read` (or `write`). Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `not_found` (404). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | All non-deleted endpoints for the project. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # List delivery attempts URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-list-deliveries.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-list-deliveries **GET /v1/projects/{projectId}/webhook-endpoints/{id}/deliveries** Keyset pagination ordered by `created_at` then `id` descending, with optional `state` and `eventType` filters. An API key needs `read` (or `write`). Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `endpoint_not_found` (404), `invalid_state` (400). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | | `id` | path | string (Ulid) | Yes | — | | `limit` | query | integer | No | — | | `cursor` | query | string | No | Opaque cursor returned as `nextCursor` by the previous page. | | `state` | query | string (WebhookDeliveryState) | No | Filter by delivery state. | | `eventType` | query | string | No | Filter by external event type. | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | One page of delivery attempts. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Reopen a circuit-broken endpoint URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-reopen.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-reopen **POST /v1/projects/{projectId}/webhook-endpoints/{id}/reopen** Resets `state` from `circuit_open` to `healthy` and zeroes the consecutive-failure counter; deliveries resume. An API key needs the `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `endpoint_not_found` (404). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | The endpoint, now healthy. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Rotate a webhook endpoint secret URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-rotate-secret.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-rotate-secret **POST /v1/projects/{projectId}/webhook-endpoints/{id}/rotate-secret** Generates a new signing secret and returns it once; the previous secret stops signing immediately. An API key needs the `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `endpoint_not_found` (404), `webhooks_unconfigured` (503). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | | `id` | path | string (Ulid) | Yes | — | ## Request body This operation takes no request body. ## Responses | Status | Meaning | | --- | --- | | `200` | New secret. Store it now; it is never shown again. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Send a test event URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-send-test.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-send-test **POST /v1/projects/{projectId}/webhook-endpoints/{id}/send-test** Queues a synthetic `video.encoding.ready` delivery to this endpoint only, with `data.__test = true` so receivers can branch in production. An API key needs the `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `endpoint_not_found` (404), `webhooks_unconfigured` (503). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | | `id` | path | string (Ulid) | Yes | — | ## Request body _optional_ **Content type:** `application/json` ## Responses | Status | Meaning | | --- | --- | | `202` | The synthetic delivery was queued. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Update a webhook endpoint URL: https://docs.videohati.com/md/en/api/webhooks/webhook-endpoints-update.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-endpoints-update **PATCH /v1/projects/{projectId}/webhook-endpoints/{id}** Toggle `enabled` or change the `url`/`description`. A changed URL is re-validated for SSRF. An API key needs the `write` scope. Rate limit: 60 requests per 60 s per caller. Error codes: `unauthorized` (401), `insufficient_scope` (403), `endpoint_not_found` (404), `invalid_url` (400), `invalid_body` (400). ## Authentication - **apiKey** — Project API key: `Authorization: Bearer vh_live_...` (live mode) or `Authorization: Bearer vh_test_...` (test mode). Keys carry `read` and/or `write` scopes. ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `projectId` | path | string (Ulid) | Yes | — | | `id` | path | string (Ulid) | Yes | — | ## Request body _required_ **Content type:** `application/json` **Schema:** `WebhookEndpointUpdateRequest` ## Responses | Status | Meaning | | --- | --- | | `200` | The updated endpoint. | | `400` | The request body or query failed validation. The per-operation description lists the exact `error.code` values. | | `401` | No valid credential was presented. | | `403` | The credential is valid but does not permit this action. The per-operation description lists the exact `error.code` values. | | `404` | The resource does not exist or is not visible to this caller. | | `429` | Rate limit exceeded. | ## Try it --- # Webhook event delivery URL: https://docs.videohati.com/md/en/api/webhooks/webhook-event-delivery.md Page: https://docs.videohati.com/en/docs/api/webhooks/webhook-event-delivery **POST your registered webhook endpoint** > This is an outbound event Videohati POSTs to your registered endpoint — you receive it, you do not call it. Outbound POST Videohati sends to a customer-registered endpoint when one of the 8 MVP events fires (delivery ships in Phase 9; the envelope and signature scheme are locked here). Each delivery carries the header `Videohati-Signature: t=,v1=` where the HMAC-SHA256 covers `.` with the endpoint's secret. Verify with constant-time comparison — the SDKs ship `webhooks.verify` helpers for this. ## Authentication - This operation needs no authentication. ## Request body _required_ **Content type:** `application/json` **Schema:** `Event` ```json { "id": "01JZ9WV3N8GQ5T2M7K4C6XBARM", "type": "video.encoding.ready", "created_at": "2026-06-11T11:00:00.000Z", "project_id": "01JZ9WV3N8GQ5T2M7K4C6XBARF", "data": { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH" } } ``` ## Responses | Status | Meaning | | --- | --- | | `200` | Acknowledge the delivery with any 2xx within 10 seconds; non-2xx responses are retried per the Phase 9 schedule. | ## Example request ```json { "id": "01JZ9WV3N8GQ5T2M7K4C6XBARM", "type": "video.encoding.ready", "created_at": "2026-06-11T11:00:00.000Z", "project_id": "01JZ9WV3N8GQ5T2M7K4C6XBARF", "data": { "videoId": "01JZ9WV3N8GQ5T2M7K4C6XBARH" } } ```