# 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 (
);
}
```
## 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"
}
}
```