# API reference

> Every Wolvy Developer API v1 endpoint: videos, folders, captions, chapters, moments, playback tokens, analytics, usage, sessions, security events, webhooks.

Source: https://docs.wolvy.net/api/ · Wolvy Developer API v1 · updated 2026-11-12

_Reference · v1_

Every endpoint of the Wolvy Developer API, with parameters, example requests in four languages, responses and the errors each one can return.

Base URL `https://api.wolvy.net` · **44** endpoints · [Postman collection ↓](https://docs.wolvy.net/downloads/wolvy-developer-api.postman_collection.json)

## Basics

| Base URL | `https://api.wolvy.net` — every route is under `/v1` except `/health` |
| --- | --- |
| Format | JSON request bodies (`Content-Type: application/json`) and JSON responses, with real HTTP status codes. `204` responses have no body. |
| Timestamps | RFC 3339 in UTC, e.g. `2026-09-15T09:30:00+00:00`. Send date filters in UTC too — an offset is dropped, not converted. |
| Sizes & times | Bytes (`_bytes` fields) and seconds (`duration`, `watch_time`, chapter and moment times). |
| IDs | Videos: hex strings (the same id as in embed URLs). Folders, webhook endpoints, deliveries and security events: integers. Chapters and moments: a positional `index`. |
| Unknown fields | Rejected with `400 unknown_parameter`, never silently ignored. |
| Browsers | Not supported. No CORS headers are sent — an API key must never be in front-end code. |
| Request ids | Every response has `X-Request-Id`. Log it; support can find any request by it. |

## Authentication & scopes

Send your key as a bearer token on every request except `/health`:

_Header_

```http
Authorization: Bearer wv_live_8kQ2…x7Pa
```

Create and revoke keys in Settings → API. A key is `wv_live_` plus 32 characters, shown once. Each key carries **scopes**, can be limited to exact IP addresses (no CIDR ranges), and can have an expiry date.

| Scope | Grants | Default |
| --- | --- | --- |
| `account:read` | Account, player settings. | Yes |
| `videos:read` | List and read videos, folders, captions, chapters, moments; embed URLs. | Yes |
| `videos:write` | Create, update and delete all of the above. | No |
| `playback:sign` | Mint viewer tokens. | Yes |
| `analytics:read` | Analytics endpoints. | Yes |
| `usage:read` | Usage, usage history, plan. | Yes |
| `sessions:read` | Viewer sessions — these include viewer IP addresses. | No |
| `security:read` | Security events. | No |
| `webhooks:manage` | Webhook endpoints and deliveries. | No |

A key created without choosing gets the defaults: read-only plus `playback:sign` — enough to build an integration, nothing that can delete a library. Missing scope → `403 insufficient_scope`; bad key → `401 invalid_api_key`.

## Errors

Errors use real status codes and one envelope. **Branch on `code`** — it is stable. `message` is for humans and may change; `param` names the offending field when there is one.

_400 · error body_

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "unknown_parameter",
    "message": "Unknown parameter 'encryption'. Allowed: source_url, title, description, folder_id.",
    "param": "encryption",
    "request_id": "req_5d1e9c2a7b3f40e8a6c1d2f3"
  }
}
```

Filter error codes

| Status | Code | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_json` | The body is not valid JSON. | Send a JSON object. |
| 400 | `unsupported_content_type` | A body was sent with a `Content-Type` other than JSON. | Set `Content-Type: application/json`. |
| 400 | `unknown_parameter` | A body field — or a query parameter on a list or date-range endpoint — this endpoint does not accept. `param` names it. | Remove it. `encryption`, `resolutions` and `keep_original` are decided by your plan and settings. |
| 400 | `missing_parameter` | A required field is absent. `param` names it. | Add the field. |
| 400 | `invalid_parameter` | A value has the wrong type, format or range — for example `limit` outside 1–100, `from` after `to`, or `end` not after `start`. | Read `message`; it says exactly what is wrong. |
| 400 | `no_fields` | A `PATCH` with nothing to change. | Send at least one field. |
| 400 | `invalid_cursor` | `starting_after` is not a cursor this API issued. | Pass `next_cursor` back unchanged. |
| 400 | `invalid_idempotency_key` | `Idempotency-Key` is longer than 255 characters. | Use a UUID. |
| 400 / 404 | `folder_not_found` | `400` when a `folder_id` you sent is not yours; `404` on `/v1/folders/{id}`. | Check the id with List folders. |
| 401 | `missing_api_key` | No `Authorization: Bearer …` header reached the API. | Send the header. Behind a proxy, make sure it forwards `Authorization`. |
| 401 | `invalid_api_key` | Unknown, malformed, revoked or deactivated key. One message for all of them, on purpose. | Copy the key again, or create a new one. |
| 401 | `expired_api_key` | The key passed its expiry date. | Create a new key. |
| 403 | `insufficient_scope` | The key lacks the scope this route needs; `message` names it. | Create a key with that scope. |
| 403 | `ip_not_allowed` | The key is restricted to IP addresses and this request came from another. Exact match — no CIDR ranges. | Add the server’s IP to the key. |
| 403 | `account_inactive` | The account is not active. | Contact support. |
| 404 | `unknown_endpoint` | No route matches the path. | Check the `/v1` prefix and spelling. |
| 404 | `not_found` | A numeric path id (folder, endpoint, chapter or moment index) is not a number. | Use the integer id. |
| 404 | `video_not_found` | The video does not exist — or belongs to another account. The two are indistinguishable by design. | Check the id and the key’s account. |
| 404 | `caption_not_found` | No caption for that language. | List captions first. |
| 404 | `chapter_not_found` | No chapter at that index. | Re-read the list — indexes shift after edits. |
| 404 | `moment_not_found` | No moment at that index. | Re-read the list — indexes shift after edits. |
| 404 | `session_not_found` | No such session on your account. | Check the session id. |
| 404 | `webhook_endpoint_not_found` | No such webhook endpoint on your account. | List endpoints. |
| 405 | `method_not_allowed` | The path exists but not with this method. The `Allow` header lists the ones that work. | Use a listed method. |
| 409 | `idempotency_key_reused` | The same `Idempotency-Key` was used with a different method, path or body. | Use a new key per operation; reuse it only for identical retries. |
| 409 | `request_in_progress` | The original request with this key is still running. | Wait a moment and retry with the same key. |
| 409 | `chapter_overlap` | The range overlaps an existing chapter, which the message names. | Adjust `start`/`end`. |
| 409 | `moment_exists` | A moment already exists at that timestamp. | Pick another second or update the existing moment. |
| 422 | `no_active_plan` | The account has no active plan. | Assign a plan in the dashboard (Billing). |
| 422 | `insufficient_storage` | The file (per its `Content-Length`) is larger than your available storage. | Free space or upgrade. |
| 422 | `invalid_source_url` | Malformed, not https, or a port other than 443/8443 — at the URL or a redirect hop. | Use a plain https URL. |
| 422 | `source_not_allowed` | The host resolves to a private or reserved address. | Serve the file from a public host. |
| 422 | `source_unreachable` | DNS failed, the connection failed, or the server answered non-2xx (a `HEAD` for videos). | Check the URL answers `HEAD` publicly. GET-only presigned URLs fail here. |
| 422 | `unsupported_source_type` | The server sent a `Content-Type` that is not video. | Serve `video/*` or `application/octet-stream`. |
| 422 | `source_too_large` | Over 20 GiB for a video, or 5 MB for a caption. | Compress or split the file. |
| 422 | `too_many_redirects` | More than 5 redirects. | Link to the final URL. |
| 422 | `invalid_caption_file` | The caption does not start with `WEBVTT`. | Convert SRT to WebVTT first. |
| 429 | `rate_limit_exceeded` | Per-minute or per-day limit reached for this key. | Wait `Retry-After` seconds. |
| 500 | `internal_error` | Something failed on our side. | Retry with backoff; quote `request_id` if it persists. |
| 503 | `service_unavailable` | The database or a media server could not be reached. | Retry shortly — nothing was changed. |

`type` groups codes broadly: `invalid_request_error` (400, 404, 405, 409, 422), `authentication_error` (401), `permission_error` (403), `rate_limit_error` (429), `api_error` (5xx). A resource that belongs to another account answers `404`, exactly like one that does not exist.

## Pagination

Lists return `{"object": "list", "data": [...], "has_more": true, "next_cursor": "…"}`. Pass `next_cursor` back as `starting_after` for the next page; `limit` is 1–100 (default 50). Cursors only go forward and are opaque — don’t build or edit them. A few short lists (captions, chapters, moments, daily and country analytics, usage history, webhook endpoints) return everything at once with `has_more: false`.

```js
// Walk every video in the library, 100 at a time.
async function* allVideos() {
  let cursor = null;
  do {
    const url = new URL("https://api.wolvy.net/v1/videos");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("starting_after", cursor);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.WOLVY_API_KEY}` } });
    const page = await res.json();
    if (!res.ok) throw new Error(`${res.status} ${page.error.code}`);

    yield* page.data;
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
}

for await (const video of allVideos()) console.log(video.id, video.status);
```

_Also on the page in Python, PHP._

## Idempotency

Any `POST` accepts an `Idempotency-Key` header (up to 255 characters — use a UUID). If a request times out and you retry it with the same key and the same body, you get the original result instead of doing the work twice.

| You send the same key… | You get |
| --- | --- |
| after the first request succeeded | The stored response, with header `Idempotent-Replay: true` |
| while the first request is still running | `409 request_in_progress` — wait and retry |
| with a different method, path or body | `409 idempotency_key_reused` |
| after the first request failed with an error | A fresh attempt — failures are not stored |
| more than 24 hours later | A fresh attempt — keys expire |

Keys are scoped to the API key that used them. Don’t use one when minting playback tokens — a replay would return a stale token.

## Rate limits

Each API key gets **120 requests per minute** and **10,000 per day** by default. The minute window is the calendar minute; the daily count resets at the start of each day. Requests refused with `401` or `403` do not count.

| Response header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` / `X-RateLimit-Remaining` | Per-minute allowance and what is left |
| `X-RateLimit-Reset` | Unix time the minute window resets |
| `X-RateLimit-Daily-Limit` / `X-RateLimit-Daily-Remaining` | The daily allowance |
| `Retry-After` | On `429` only: seconds to wait |

Need more? Ask support to raise a key’s limits. A client that handles `429` and `5xx` gracefully:

```js
import { randomUUID } from "node:crypto";

// Retries 429 and 5xx with backoff. One Idempotency-Key per logical operation,
// reused on every retry, so a POST can never run twice.
export async function wolvy(method, path, body) {
  const key = method === "POST" ? randomUUID() : undefined;
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://api.wolvy.net${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${process.env.WOLVY_API_KEY}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
        ...(key ? { "Idempotency-Key": key } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (res.status === 204) return null;
    const data = await res.json();
    if (res.ok) return data;

    const retryable = res.status === 429 || res.status >= 500 || data.error?.code === "request_in_progress";
    if (!retryable || attempt === 5) {
      const err = new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
      err.code = data.error.code;
      err.requestId = data.error.request_id;
      throw err;
    }
    const waitSeconds = Number(res.headers.get("Retry-After")) || 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
  }
}
```

## Request builder

Choose an endpoint, fill in its parameters and copy a working request. Nothing is sent from this page.

_[Interactive: Request Builder — runs on https://docs.wolvy.net/api/]_

## All endpoints

Filter endpoints

Health

- [GET/healthHealth check](https://docs.wolvy.net/api/#get-health)

Account

- [GET/v1/accountGet account](https://docs.wolvy.net/api/#get-account)
- [GET/v1/settings/playerGet player settings](https://docs.wolvy.net/api/#get-player-settings)

Videos

- [GET/v1/videosList videos](https://docs.wolvy.net/api/#list-videos)
- [POST/v1/videosCreate a video (URL ingest)](https://docs.wolvy.net/api/#create-video)
- [GET/v1/videos/{id}Get a video](https://docs.wolvy.net/api/#get-video)
- [GET/v1/videos/{id}/statusGet video status](https://docs.wolvy.net/api/#get-video-status)
- [PATCH/v1/videos/{id}Update a video](https://docs.wolvy.net/api/#update-video)
- [DELETE/v1/videos/{id}Delete a video](https://docs.wolvy.net/api/#delete-video)

Folders

- [GET/v1/foldersList folders](https://docs.wolvy.net/api/#list-folders)
- [POST/v1/foldersCreate a folder](https://docs.wolvy.net/api/#create-folder)
- [GET/v1/folders/{id}Get a folder](https://docs.wolvy.net/api/#get-folder)
- [DELETE/v1/folders/{id}Delete a folder](https://docs.wolvy.net/api/#delete-folder)

Captions

- [GET/v1/videos/{id}/captionsList captions](https://docs.wolvy.net/api/#list-captions)
- [POST/v1/videos/{id}/captionsAdd or replace a caption](https://docs.wolvy.net/api/#add-caption)
- [DELETE/v1/videos/{id}/captions/{language}Delete a caption](https://docs.wolvy.net/api/#delete-caption)

Chapters

- [GET/v1/videos/{id}/chaptersList chapters](https://docs.wolvy.net/api/#list-chapters)
- [POST/v1/videos/{id}/chaptersAdd a chapter](https://docs.wolvy.net/api/#add-chapter)
- [PATCH/v1/videos/{id}/chapters/{index}Update a chapter](https://docs.wolvy.net/api/#update-chapter)
- [DELETE/v1/videos/{id}/chapters/{index}Delete a chapter](https://docs.wolvy.net/api/#delete-chapter)

Moments

- [GET/v1/videos/{id}/momentsList moments](https://docs.wolvy.net/api/#list-moments)
- [POST/v1/videos/{id}/momentsAdd a moment](https://docs.wolvy.net/api/#add-moment)
- [PATCH/v1/videos/{id}/moments/{index}Update a moment](https://docs.wolvy.net/api/#update-moment)
- [DELETE/v1/videos/{id}/moments/{index}Delete a moment](https://docs.wolvy.net/api/#delete-moment)

Playback

- [POST/v1/videos/{id}/playback-tokensCreate a playback token](https://docs.wolvy.net/api/#create-playback-token)
- [GET/v1/videos/{id}/embedGet embed URL](https://docs.wolvy.net/api/#get-embed)

Analytics

- [GET/v1/analytics/overviewOverview](https://docs.wolvy.net/api/#analytics-overview)
- [GET/v1/analytics/dailyDaily](https://docs.wolvy.net/api/#analytics-daily)
- [GET/v1/analytics/countriesBy country](https://docs.wolvy.net/api/#analytics-countries)
- [GET/v1/videos/{id}/analyticsFor one video](https://docs.wolvy.net/api/#video-analytics)

Usage & plan

- [GET/v1/usageGet usage](https://docs.wolvy.net/api/#get-usage)
- [GET/v1/usage/historyUsage history](https://docs.wolvy.net/api/#usage-history)
- [GET/v1/planGet plan](https://docs.wolvy.net/api/#get-plan)

Viewer sessions

- [GET/v1/sessionsList sessions](https://docs.wolvy.net/api/#list-sessions)
- [GET/v1/sessions/{session_id}Get a session](https://docs.wolvy.net/api/#get-session)
- [GET/v1/videos/{id}/sessionsSessions for one video](https://docs.wolvy.net/api/#video-sessions)

Security events

- [GET/v1/security-eventsList security events](https://docs.wolvy.net/api/#list-security-events)

Webhooks

- [GET/v1/webhook-endpointsList endpoints](https://docs.wolvy.net/api/#list-webhook-endpoints)
- [POST/v1/webhook-endpointsCreate an endpoint](https://docs.wolvy.net/api/#create-webhook-endpoint)
- [GET/v1/webhook-endpoints/{id}Get an endpoint](https://docs.wolvy.net/api/#get-webhook-endpoint)
- [PATCH/v1/webhook-endpoints/{id}Update an endpoint](https://docs.wolvy.net/api/#update-webhook-endpoint)
- [DELETE/v1/webhook-endpoints/{id}Delete an endpoint](https://docs.wolvy.net/api/#delete-webhook-endpoint)
- [POST/v1/webhook-endpoints/{id}/testSend a test delivery](https://docs.wolvy.net/api/#test-webhook-endpoint)
- [GET/v1/webhook-deliveriesList deliveries](https://docs.wolvy.net/api/#list-webhook-deliveries)

_Resource_

## Health

An unauthenticated liveness probe. It deliberately touches the database, so it goes red through the failure that matters rather than only proving PHP runs.

### Health check

GET `/health`

No authentication · Returns `200`

No API key. Note the path has no `/v1` prefix.

- When the database is unreachable the answer is `503` — either `{"object":"health","status":"degraded","database":"unreachable"}` or a `service_unavailable` error, depending on where the connection failed. Alert on the status code, not the body.

#### Request

```bash
curl "https://api.wolvy.net/health"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "health",
  "status": "ok",
  "database": "ok"
}
```

Errors you may see: [**503**service_unavailable](https://docs.wolvy.net/api/#error-service_unavailable)

_Resource_

## Account

Who the key belongs to, the plan behind it, and the account-wide player settings every embed inherits.

### Get account

GET `/v1/account`

Scope `account:read` · Returns `200`

The natural first call: it proves the key works and tells you its scopes and limits. `id` is your **account id** — base64-encode it for embed URLs.

- `plan` is `null` when the account has no active plan — uploads fail with `422 no_active_plan` until one is assigned.

#### Request

```bash
curl "https://api.wolvy.net/v1/account" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "account",
  "id": 42,
  "name": "Acme Academy",
  "username": "acme",
  "email": "dev@example.com",
  "status": "active",
  "created_at": "2026-03-02T10:15:00+00:00",
  "plan": {
    "name": "DRM Pro",
    "type": "fixed",
    "protection": "drm",
    "end_date": "2027-03-02T00:00:00+00:00"
  },
  "api_key": {
    "id": 17,
    "name": "Production backend",
    "prefix": "wv_live_8kQ2",
    "last_four": "x7Pa",
    "scopes": [
      "account:read",
      "videos:read",
      "videos:write",
      "playback:sign"
    ],
    "created_at": "2026-09-01T08:00:00+00:00"
  },
  "rate_limits": {
    "per_minute": 120,
    "per_day": 10000,
    "requests_today": 318
  }
}
```

Errors you may see: [**401**missing_api_key](https://docs.wolvy.net/api/#error-missing_api_key)[**401**invalid_api_key](https://docs.wolvy.net/api/#error-invalid_api_key)[**401**expired_api_key](https://docs.wolvy.net/api/#error-expired_api_key)[**403**insufficient_scope](https://docs.wolvy.net/api/#error-insufficient_scope)

### Get player settings

GET `/v1/settings/player`

Scope `account:read` · Returns `200`

Read-only view of theme, colours, resolutions, domain rules, subtitle style, watermark and App-Only mode. Change them in the dashboard — they apply to every embed on the account.

- Check `watermark.required`: when it is `true` and the watermark is enabled, playback is refused without a valid viewer token, so every embed must carry `?vt=`. See [Sign your viewers](https://docs.wolvy.net/viewer-tokens/#enforcement).
- An empty `allowed_domains` means embeds work on any site that sends a Referer. See [Domain rules](https://docs.wolvy.net/embedding/#domains).

#### Request

```bash
curl "https://api.wolvy.net/v1/settings/player" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "player_settings",
  "theme": "1",
  "colors": {
    "player": "#7e3dd9",
    "accent": "#7e3dd9",
    "background": "#f7ee7f"
  },
  "available_resolutions": [
    "240",
    "360",
    "480",
    "720"
  ],
  "allowed_domains": [
    "academy.example.com",
    "*.example.com"
  ],
  "blocked_domains": [],
  "subtitles": {
    "font": "sans",
    "color": "#ffffff",
    "background": "transparent",
    "size": "medium"
  },
  "watermark": {
    "enabled": true,
    "mode": "moving",
    "position": "bottom-right",
    "opacity": 40,
    "required": true
  },
  "app_only_l3": false,
  "keep_original": true
}
```

Errors you may see: [**403**insufficient_scope](https://docs.wolvy.net/api/#error-insufficient_scope)

_Resource_

## Videos

Your library. Videos are created by **URL ingest** — Wolvy fetches the file — and move through `queued → processing → playable → ready` (or `failed`). Encryption, the resolution ladder and original-file retention come from the plan and account settings, so they are not request fields.

The video object — fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | The video id. Also the first path segment of the embed URL. |
| `status` | string | `queued` · `processing` · `playable` (first resolution done, watchable) · `ready` · `failed`. |
| `protection` | string | `drm` or `clearkey`. Read-only; follows the account’s current plan. |
| `duration` | number \| null | Seconds. `null` until the video is `ready`. |
| `size_bytes` | integer | `0` until the video is `ready`. |
| `folder_id` | integer \| null | `null` means the root of the library. |
| `embed_url` | string | Unsigned embed URL, present from the moment the video exists. Add `?vt=` for signed playback. |
| `direct_play_url` | string \| null | The hosted play page; `null` when Direct Play is off for the account. |
| `hls_url` | string \| null | The encrypted HLS manifest. It plays through the Wolvy player, not a generic HLS player. |
| `poster_url · animated_poster_url` | string \| null | Poster images, generated during encoding. |
| `original_url` | string \| null | For API uploads, the `source_url` you supplied. |
| `captions · chapters · moments` | array | Same shapes as their own endpoints return. |
| `created_at · updated_at` | timestamp | RFC 3339, UTC. |

### List videos

GET `/v1/videos`

Scope `videos:read` · Returns `200`

Newest first, cursor-paginated. Omitting `folder_id` returns the **whole library**, including everything filed in folders.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `limit` | integer | Page size, `1`–`100`. Default `50`. |
| `starting_after` | string | The `next_cursor` from the previous page. Omit for the first page. |
| `folder_id` | string | A folder id, or the literal `root` for unfiled videos. |
| `status` | enum | Only videos in this status. |
| `q` | string | Substring match on title and description. |
| `created_after` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC — an offset is dropped, not converted. |
| `created_before` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC. |

#### Request

```bash
curl "https://api.wolvy.net/v1/videos?limit=20&status=ready" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "id": "a1b2c3d4e5f60718293a",
      "object": "video",
      "title": "Onboarding",
      "description": "Week one.",
      "status": "ready",
      "protection": "drm",
      "duration": 724.5,
      "size_bytes": 1288490188,
      "folder_id": 7,
      "embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=",
      "direct_play_url": null,
      "hls_url": "https://…/master.m3u8",
      "poster_url": "https://…/poster.jpg",
      "animated_poster_url": null,
      "original_url": "https://media.example.com/uploads/onboarding.mp4",
      "captions": [
        {
          "label": "English",
          "language_code": "en"
        }
      ],
      "chapters": [
        {
          "title": "Introduction",
          "start": 0,
          "end": 45
        }
      ],
      "moments": [
        {
          "label": "Live demo",
          "timestamp": 120
        }
      ],
      "created_at": "2026-09-15T09:30:00+00:00",
      "updated_at": "2026-09-15T09:52:41+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJkIjoiMjAyNi0wOS0xMiAxNjowMjoxMSIsImkiOjkxMn0"
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)[**400**invalid_cursor](https://docs.wolvy.net/api/#error-invalid_cursor)

### Create a video (URL ingest)

POST `/v1/videos`

Scope `videos:write` · Returns `202` Accepted · Supports `Idempotency-Key`

Wolvy validates the URL, answers `202` immediately with `status: "queued"`, then downloads and encodes in the background. Poll [the status endpoint](https://docs.wolvy.net/api/#get-video-status) or wait for the `video.ready` webhook.

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `source_url` (required) | string | Public **https** URL of the file (port 443 or 8443). Must answer `HEAD` with a 2xx and stay reachable until the download finishes. Max 2048 characters. |
| `title` | string | Up to 255 characters. Defaults to the file name from the URL path. |
| `description` | string | Free text. |
| `folder_id` | integer | Put the video in this folder. |

- There is **no** `encryption`, `resolutions` or `keep_original` field — sending one returns `400 unknown_parameter`. Multi-DRM vs ClearKey follows your plan.
- The URL is re-checked before the download and on every redirect hop. Full rules: [Upload by URL](https://docs.wolvy.net/uploads/#source-rules).

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/videos" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
  -d '{
    "source_url": "https://media.example.com/uploads/onboarding.mp4",
    "title": "Onboarding",
    "description": "Week one."
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 202

_202 · application/json_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "title": "Onboarding",
  "description": "Week one.",
  "status": "queued",
  "protection": "drm",
  "duration": null,
  "size_bytes": 0,
  "folder_id": null,
  "embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=",
  "direct_play_url": null,
  "hls_url": null,
  "poster_url": null,
  "animated_poster_url": null,
  "original_url": "https://media.example.com/uploads/onboarding.mp4",
  "captions": [],
  "chapters": [],
  "moments": [],
  "created_at": "2026-09-15T09:30:00+00:00",
  "updated_at": "2026-09-15T09:30:00+00:00"
}
```

Errors you may see: [**422**no_active_plan](https://docs.wolvy.net/api/#error-no_active_plan)[**422**insufficient_storage](https://docs.wolvy.net/api/#error-insufficient_storage)[**422**invalid_source_url](https://docs.wolvy.net/api/#error-invalid_source_url)[**422**source_not_allowed](https://docs.wolvy.net/api/#error-source_not_allowed)[**422**source_unreachable](https://docs.wolvy.net/api/#error-source_unreachable)[**422**unsupported_source_type](https://docs.wolvy.net/api/#error-unsupported_source_type)[**422**source_too_large](https://docs.wolvy.net/api/#error-source_too_large)[**422**too_many_redirects](https://docs.wolvy.net/api/#error-too_many_redirects)[**400**folder_not_found](https://docs.wolvy.net/api/#error-folder_not_found)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)

### Get a video

GET `/v1/videos/{id}`

Scope `videos:read` · Returns `200`

The full video object.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "title": "Onboarding",
  "description": "Week one.",
  "status": "ready",
  "protection": "drm",
  "duration": 724.5,
  "size_bytes": 1288490188,
  "folder_id": 7,
  "embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=",
  "direct_play_url": null,
  "hls_url": "https://…/master.m3u8",
  "poster_url": "https://…/poster.jpg",
  "animated_poster_url": null,
  "original_url": "https://media.example.com/uploads/onboarding.mp4",
  "captions": [
    {
      "label": "English",
      "language_code": "en"
    }
  ],
  "chapters": [
    {
      "title": "Introduction",
      "start": 0,
      "end": 45
    }
  ],
  "moments": [
    {
      "label": "Live demo",
      "timestamp": 120
    }
  ],
  "created_at": "2026-09-15T09:30:00+00:00",
  "updated_at": "2026-09-15T09:52:41+00:00"
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### Get video status

GET `/v1/videos/{id}/status`

Scope `videos:read` · Returns `200`

A minimal payload — **this is the endpoint to poll** while encoding runs, not the full object.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

- Poll every 10–30 seconds and stop at `ready` or `failed`. Better still, [listen for webhooks](https://docs.wolvy.net/webhooks/).

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/status" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video_status",
  "status": "processing",
  "duration": null,
  "size_bytes": 0,
  "updated_at": "2026-09-15T09:34:10+00:00"
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### Update a video

PATCH `/v1/videos/{id}`

Scope `videos:write` · Returns `200`

Change the title, description or folder. Send at least one field.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `title` | string | 1–255 characters. |
| `description` | string \| null | Free text. |
| `folder_id` | integer \| null | Move to this folder; `null` moves it to the root. |

#### Request

```bash
curl -X PATCH "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Onboarding (revised)",
    "folder_id": 7
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "title": "Onboarding (revised)",
  "description": "Week one.",
  "status": "ready",
  "protection": "drm",
  "duration": 724.5,
  "size_bytes": 1288490188,
  "folder_id": 7,
  "embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=",
  "direct_play_url": null,
  "hls_url": "https://…/master.m3u8",
  "poster_url": "https://…/poster.jpg",
  "animated_poster_url": null,
  "original_url": "https://media.example.com/uploads/onboarding.mp4",
  "captions": [
    {
      "label": "English",
      "language_code": "en"
    }
  ],
  "chapters": [
    {
      "title": "Introduction",
      "start": 0,
      "end": 45
    }
  ],
  "moments": [
    {
      "label": "Live demo",
      "timestamp": 120
    }
  ],
  "created_at": "2026-09-15T09:30:00+00:00",
  "updated_at": "2026-09-15T09:52:41+00:00"
}
```

Errors you may see: [**400**no_fields](https://docs.wolvy.net/api/#error-no_fields)[**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)[**400**folder_not_found](https://docs.wolvy.net/api/#error-folder_not_found)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)

### Delete a video

DELETE `/v1/videos/{id}`

Scope `videos:write` · Returns `204` No Content

**Irreversible.** Removes the media files, encryption keys and analytics. Viewer sessions and security events are kept, so your audit trail survives.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

- Emits `video.deleted`. Deletions made in the dashboard do not.
- `503` means the media server could not be reached and the video is still in your library — retry.

#### Request

```bash
curl -X DELETE "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 204

No body.

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)[**503**service_unavailable](https://docs.wolvy.net/api/#error-service_unavailable)

_Resource_

## Folders

Folders are a **flat set**, not a tree, and cannot be renamed. Move videos between them with [Update a video](https://docs.wolvy.net/api/#update-video).

### List folders

GET `/v1/folders`

Scope `videos:read` · Returns `200`

Newest first, cursor-paginated.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `limit` | integer | Page size, `1`–`100`. Default `50`. |
| `starting_after` | string | The `next_cursor` from the previous page. Omit for the first page. |

#### Request

```bash
curl "https://api.wolvy.net/v1/folders?limit=20" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "id": 7,
      "object": "folder",
      "name": "Onboarding",
      "video_count": 12,
      "created_at": "2026-09-02T00:00:00+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**invalid_cursor](https://docs.wolvy.net/api/#error-invalid_cursor)

### Create a folder

POST `/v1/folders`

Scope `videos:write` · Returns `201` Created · Supports `Idempotency-Key`

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `name` (required) | string | 1–255 characters. |

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/folders" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
  -d '{
    "name": "Onboarding"
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 201

_201 · application/json_

```json
{
  "id": 7,
  "object": "folder",
  "name": "Onboarding",
  "video_count": 0,
  "created_at": "2026-09-15T00:00:00+00:00"
}
```

Errors you may see: [**400**missing_parameter](https://docs.wolvy.net/api/#error-missing_parameter)[**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)

### Get a folder

GET `/v1/folders/{id}`

Scope `videos:read` · Returns `200`

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | integer | Folder id. |

#### Request

```bash
curl "https://api.wolvy.net/v1/folders/7" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "id": 7,
  "object": "folder",
  "name": "Onboarding",
  "video_count": 12,
  "created_at": "2026-09-02T00:00:00+00:00"
}
```

Errors you may see: [**400**folder_not_found](https://docs.wolvy.net/api/#error-folder_not_found)[**404**not_found](https://docs.wolvy.net/api/#error-not_found)

### Delete a folder

DELETE `/v1/folders/{id}`

Scope `videos:write` · Returns `204` No Content

**The videos inside are not deleted** — they move to the root of the library.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | integer | Folder id. |

#### Request

```bash
curl -X DELETE "https://api.wolvy.net/v1/folders/7" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 204

No body.

Errors you may see: [**400**folder_not_found](https://docs.wolvy.net/api/#error-folder_not_found)

_Resource_

## Captions

WebVTT subtitle tracks. `language_code` is the identifier: posting a language that already exists replaces it.

### List captions

GET `/v1/videos/{id}/captions`

Scope `videos:read` · Returns `200`

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/captions" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "object": "caption",
      "language_code": "en",
      "label": "English"
    },
    {
      "object": "caption",
      "language_code": "ar",
      "label": "العربية"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### Add or replace a caption

POST `/v1/videos/{id}/captions`

Scope `videos:write` · Returns `201` Created · Supports `Idempotency-Key`

Send **exactly one** of `content` (the VTT text) or `source_url`. Returns the full caption list.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `language_code` (required) | string | A language tag such as `en`, `ar` or `pt-BR` — matches `^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$`. |
| `label` | string | What viewers see in the captions menu. Defaults to the language code. |
| `content` | string | The WebVTT file itself. Must contain `WEBVTT` in its first 64 bytes. Max 5 MB. |
| `source_url` | string | Public https URL of a `.vtt` file, fetched through the same guard as video ingest. Max 5 MB. |

- Emits `caption.ready` once the change is picked up (usually within a minute).

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/captions" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
  -d '{
    "language_code": "en",
    "label": "English",
    "content": "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHello and welcome.\n"
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 201

_201 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "object": "caption",
      "language_code": "en",
      "label": "English"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**422**invalid_caption_file](https://docs.wolvy.net/api/#error-invalid_caption_file)[**422**source_unreachable](https://docs.wolvy.net/api/#error-source_unreachable)[**422**source_too_large](https://docs.wolvy.net/api/#error-source_too_large)[**422**source_not_allowed](https://docs.wolvy.net/api/#error-source_not_allowed)[**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)[**503**service_unavailable](https://docs.wolvy.net/api/#error-service_unavailable)

### Delete a caption

DELETE `/v1/videos/{id}/captions/{language}`

Scope `videos:write` · Returns `204` No Content

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |
| `language` (required) | string | The `language_code` to remove. |

#### Request

```bash
curl -X DELETE "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/captions/en" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 204

No body.

Errors you may see: [**404**caption_not_found](https://docs.wolvy.net/api/#error-caption_not_found)[**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)[**503**service_unavailable](https://docs.wolvy.net/api/#error-service_unavailable)

_Resource_

## Chapters

Named segments on the progress bar. Times are whole seconds and chapters **may not overlap**. They are addressed by `index` — the position in the list, sorted by `start` — so an edit can renumber them: re-read the list after every change instead of caching indexes.

### List chapters

GET `/v1/videos/{id}/chapters`

Scope `videos:read` · Returns `200`

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/chapters" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "index": 0,
      "object": "chapter",
      "title": "Introduction",
      "start": 0,
      "end": 45
    },
    {
      "index": 1,
      "object": "chapter",
      "title": "Setting up",
      "start": 45,
      "end": 210
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### Add a chapter

POST `/v1/videos/{id}/chapters`

Scope `videos:write` · Returns `201` Created · Supports `Idempotency-Key`

Returns the full, re-sorted list.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `title` (required) | string | 1–255 characters. |
| `start` (required) | integer | Seconds, `0` or more. |
| `end` (required) | integer | Seconds; must be greater than `start`. |

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/chapters" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
  -d '{
    "title": "Introduction",
    "start": 0,
    "end": 45
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 201

_201 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "index": 0,
      "object": "chapter",
      "title": "Introduction",
      "start": 0,
      "end": 45
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**409**chapter_overlap](https://docs.wolvy.net/api/#error-chapter_overlap)[**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**missing_parameter](https://docs.wolvy.net/api/#error-missing_parameter)[**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### Update a chapter

PATCH `/v1/videos/{id}/chapters/{index}`

Scope `videos:write` · Returns `200`

Any of `title`, `start`, `end`. Returns the full, re-sorted list.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |
| `index` (required) | integer | Position in the current list. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `title` | string | 1–255 characters. |
| `start` | integer | Seconds. |
| `end` | integer | Seconds; greater than `start`. |

#### Request

```bash
curl -X PATCH "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/chapters/0" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Introduction & setup"
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "index": 0,
      "object": "chapter",
      "title": "Introduction & setup",
      "start": 0,
      "end": 45
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**400**no_fields](https://docs.wolvy.net/api/#error-no_fields)[**404**chapter_not_found](https://docs.wolvy.net/api/#error-chapter_not_found)[**409**chapter_overlap](https://docs.wolvy.net/api/#error-chapter_overlap)[**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)

### Delete a chapter

DELETE `/v1/videos/{id}/chapters/{index}`

Scope `videos:write` · Returns `204` No Content

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |
| `index` (required) | integer | Position in the current list. |

#### Request

```bash
curl -X DELETE "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/chapters/0" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 204

No body.

Errors you may see: [**404**chapter_not_found](https://docs.wolvy.net/api/#error-chapter_not_found)

_Resource_

## Moments

Single-point markers — "highlights" in the dashboard. **Timestamps are unique** within a video. Indexed like chapters, sorted by `timestamp`.

### List moments

GET `/v1/videos/{id}/moments`

Scope `videos:read` · Returns `200`

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/moments" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "index": 0,
      "object": "moment",
      "label": "Live demo",
      "timestamp": 120
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### Add a moment

POST `/v1/videos/{id}/moments`

Scope `videos:write` · Returns `201` Created · Supports `Idempotency-Key`

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `label` (required) | string | 1–255 characters. |
| `timestamp` (required) | integer | Seconds, `0` or more. Unique within the video. |

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/moments" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
  -d '{
    "label": "Live demo",
    "timestamp": 120
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 201

_201 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "index": 0,
      "object": "moment",
      "label": "Live demo",
      "timestamp": 120
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**409**moment_exists](https://docs.wolvy.net/api/#error-moment_exists)[**400**missing_parameter](https://docs.wolvy.net/api/#error-missing_parameter)[**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### Update a moment

PATCH `/v1/videos/{id}/moments/{index}`

Scope `videos:write` · Returns `200`

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |
| `index` (required) | integer | Position in the current list. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `label` | string | 1–255 characters. |
| `timestamp` | integer | Seconds. |

#### Request

```bash
curl -X PATCH "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/moments/0" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Live demo (updated)"
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "index": 0,
      "object": "moment",
      "label": "Live demo (updated)",
      "timestamp": 120
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**400**no_fields](https://docs.wolvy.net/api/#error-no_fields)[**404**moment_not_found](https://docs.wolvy.net/api/#error-moment_not_found)[**409**moment_exists](https://docs.wolvy.net/api/#error-moment_exists)

### Delete a moment

DELETE `/v1/videos/{id}/moments/{index}`

Scope `videos:write` · Returns `204` No Content

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |
| `index` (required) | integer | Position in the current list. |

#### Request

```bash
curl -X DELETE "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/moments/0" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 204

No body.

Errors you may see: [**404**moment_not_found](https://docs.wolvy.net/api/#error-moment_not_found)

_Resource_

## Playback

Embed URLs and signed viewer tokens. The how and why lives in two guides: [Sign your viewers](https://docs.wolvy.net/viewer-tokens/) and [Embed the player](https://docs.wolvy.net/embedding/).

### Create a playback token

POST `/v1/videos/{id}/playback-tokens`

Scope `playback:sign` · Returns `201` Created · Supports `Idempotency-Key`

Mints a signed viewer token (`vt`) for one viewer and returns ready-to-use embed markup. Call it **from your backend** when you render a page for a signed-in viewer.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `viewer_id` (required) | string | Your identifier for the person watching, 1–64 characters. It is drawn on the watermark and recorded against the playback session. |
| `ttl_seconds` | integer | Token lifetime, `60`–`86400`. Default `21600` (6 hours). |

- The token is **not bound to this video** — its payload is only `{vid, exp}`, so one token plays every video on the account until it expires. The `{id}` only shapes the returned URLs.
- Minting through the API costs a request each time. On busy pages, [sign locally](https://docs.wolvy.net/viewer-tokens/#sign-locally) with your signing secret instead — the output is byte-identical.
- `deep_link` opens the video in the Wolvy Player Android app. See [App-Only mode](https://docs.wolvy.net/embedding/#app-only).

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/playback-tokens" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
  -d '{
    "viewer_id": "user-8813",
    "ttl_seconds": 7200
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 201

_201 · application/json_

```json
{
  "object": "playback_token",
  "video_id": "a1b2c3d4e5f60718293a",
  "viewer_id": "user-8813",
  "vt": "eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
  "expires_at": "2026-09-21T14:13:20+00:00",
  "embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=?vt=eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
  "deep_link": "wolvy://play/a1b2c3d4e5f60718293a/NDI=/eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
  "iframe_html": "<iframe src=\"https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=?vt=eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM\" width=\"100%\" height=\"100%\" frameborder=\"0\" allow=\"autoplay; fullscreen; encrypted-media\" allowfullscreen></iframe>"
}
```

Errors you may see: [**400**missing_parameter](https://docs.wolvy.net/api/#error-missing_parameter)[**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)[**403**insufficient_scope](https://docs.wolvy.net/api/#error-insufficient_scope)

### Get embed URL

GET `/v1/videos/{id}/embed`

Scope `videos:read` · Returns `200`

The **unsigned** embed URL and an iframe snippet.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

- Pasting `embed_url` into the address bar shows a 403 page — no Referer is sent. That is the domain check working, not a broken link.
- For DRM playback add `picture-in-picture` to `allow` and `referrerpolicy="strict-origin-when-cross-origin"`. [Recommended iframe](https://docs.wolvy.net/embedding/#iframe).

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/embed" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "embed",
  "video_id": "a1b2c3d4e5f60718293a",
  "embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=",
  "iframe_html": "<iframe src=\"https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=\" width=\"100%\" height=\"100%\" frameborder=\"0\" allow=\"autoplay; fullscreen; encrypted-media\" allowfullscreen></iframe>",
  "direct_play_url": null,
  "note": "The embed URL enforces the account's domain allowlist by Referer, so it returns 403 when opened outside an approved page."
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

_Resource_

## Analytics

Views, watch time and delivery totals. `watch_time` is seconds; every storage and bandwidth value is bytes. Ranges are `from`/`to` dates, defaulting to the last 30 days.

### Overview

GET `/v1/analytics/overview`

Scope `analytics:read` · Returns `200`

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `from` | date | `YYYY-MM-DD`. Default: 29 days before `to`. |
| `to` | date | `YYYY-MM-DD`. Default: today. |

- `storage_bytes` is the **peak** over the range — storage is a level, not a flow. `bandwidth_bytes` is summed.
- `video_count` is the current library size, not limited to the range.

#### Request

```bash
curl "https://api.wolvy.net/v1/analytics/overview?from=2026-09-01&to=2026-09-15" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "analytics_overview",
  "from": "2026-09-01",
  "to": "2026-09-15",
  "views": 18240,
  "watch_time": 2911860,
  "storage_bytes": 96636764160,
  "bandwidth_bytes": 412316860416,
  "video_count": 118
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)

### Daily

GET `/v1/analytics/daily`

Scope `analytics:read` · Returns `200`

One row per day, oldest first. Not paginated.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `from` | date | `YYYY-MM-DD`. Default: 29 days before `to`. |
| `to` | date | `YYYY-MM-DD`. Default: today. |

#### Request

```bash
curl "https://api.wolvy.net/v1/analytics/daily?from=2026-09-01&to=2026-09-15" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "object": "analytics_day",
      "date": "2026-09-14",
      "views": 1302,
      "watch_time": 204118,
      "storage_bytes": 96636764160,
      "bandwidth_bytes": 29451206400
    },
    {
      "object": "analytics_day",
      "date": "2026-09-15",
      "views": 1177,
      "watch_time": 187304,
      "storage_bytes": 96636764160,
      "bandwidth_bytes": 26843545600
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)

### By country

GET `/v1/analytics/countries`

Scope `analytics:read` · Returns `200`

Sorted by views, highest first. Not paginated.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `from` | date | `YYYY-MM-DD`. Default: 29 days before `to`. |
| `to` | date | `YYYY-MM-DD`. Default: today. |
| `video_id` | string | Scope to one video. **Lifetime** figures — the date range is ignored. |

#### Request

```bash
curl "https://api.wolvy.net/v1/analytics/countries?from=2026-09-01&to=2026-09-15" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "object": "analytics_country",
      "country": "EG",
      "views": 11020,
      "watch_time": 1830441
    },
    {
      "object": "analytics_country",
      "country": "SA",
      "views": 3310,
      "watch_time": 512880
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)[**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

### For one video

GET `/v1/videos/{id}/analytics`

Scope `analytics:read` · Returns `200`

Lifetime totals for a single video.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

- `views` and `sessions` count different things: `views` is the aggregate counter, `sessions` counts tracked playback sessions that actually played. Expect them to differ.

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/analytics" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "video_analytics",
  "video_id": "a1b2c3d4e5f60718293a",
  "views": 2310,
  "sessions": 1874,
  "watch_time": 488120,
  "engagement_score": 0.71,
  "countries": {
    "EG": 1402,
    "SA": 511,
    "AE": 397
  }
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)

_Resource_

## Usage & plan

Quota and plan state. Every value is **bytes** (1 GB = 1,073,741,824) with a `_bytes` suffix, so nothing needs guessing.

### Get usage

GET `/v1/usage`

Scope `usage:read` · Returns `200`

- `danger_zone` turns `true` when the account is close to its allowance — worth alerting on before uploads start failing.

#### Request

```bash
curl "https://api.wolvy.net/v1/usage" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "usage",
  "storage": {
    "limit_bytes": 536870912000,
    "used_bytes": 96636764160,
    "available_bytes": 440234147840
  },
  "bandwidth": {
    "limit_bytes": 1099511627776,
    "used_bytes": 412316860416,
    "available_bytes": 687194767360
  },
  "danger_zone": false,
  "period": {
    "start": "2026-09-02T00:00:00+00:00",
    "end": "2026-10-02T00:00:00+00:00"
  }
}
```

Errors you may see: [**422**no_active_plan](https://docs.wolvy.net/api/#error-no_active_plan)

### Usage history

GET `/v1/usage/history`

Scope `usage:read` · Returns `200`

Daily rows from the usage ledger, oldest first. Not paginated.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `from` | date | `YYYY-MM-DD`. Default: 29 days before `to`. |
| `to` | date | `YYYY-MM-DD`. Default: today. |

#### Request

```bash
curl "https://api.wolvy.net/v1/usage/history?from=2026-09-01&to=2026-09-15" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "object": "usage_day",
      "date": "2026-09-15",
      "storage_bytes": 96636764160,
      "bandwidth_bytes": 26843545600
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)

### Get plan

GET `/v1/plan`

Scope `usage:read` · Returns `200`

- `protection` is why creating a video has no encryption parameter. Changing it means changing plan, in the dashboard.

#### Request

```bash
curl "https://api.wolvy.net/v1/plan" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "plan",
  "name": "DRM Pro",
  "type": "fixed",
  "protection": "drm",
  "storage_limit_bytes": 536870912000,
  "bandwidth_limit_bytes": 1099511627776,
  "start_date": "2026-09-02T00:00:00+00:00",
  "end_date": "2026-10-02T00:00:00+00:00"
}
```

Errors you may see: [**422**no_active_plan](https://docs.wolvy.net/api/#error-no_active_plan)

_Resource_

## Viewer sessions

One row per tracked playback. `viewer_id` is filled only when the session started with a signed viewer token — and then it is **server-verified**, never something the viewer could set. Sessions outlive the video they describe.

> **Security — These records contain personal data**
> Sessions include your viewers’ IP addresses and browser user agents; you are the data controller for them. If you would rather not receive full addresses, ask support to enable IP redaction — the last part of each address is then zeroed.

### List sessions

GET `/v1/sessions`

Scope `sessions:read` · Returns `200`

Newest first. Lists only sessions where something actually played (`watch_seconds > 0`).

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `limit` | integer | Page size, `1`–`100`. Default `50`. |
| `starting_after` | string | The `next_cursor` from the previous page. Omit for the first page. |
| `video_id` | string | Only sessions for this video. |
| `viewer_id` | string | Only sessions for this verified viewer. |
| `country` | string | Two-letter country code. |
| `created_after` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC — an offset is dropped, not converted. |
| `created_before` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC. |

#### Request

```bash
curl "https://api.wolvy.net/v1/sessions?limit=20&viewer_id=user-8813" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "id": "9f2b6c1d3e4a5b60718293a4b5c6d7e8",
      "object": "viewer_session",
      "video_id": "a1b2c3d4e5f60718293a",
      "viewer_id": "user-8813",
      "watch_seconds": 1312,
      "bytes_streamed": 734003200,
      "country": "EG",
      "ip": "203.0.113.24",
      "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36 Edg/140.0",
      "created_at": "2026-09-15T10:40:02+00:00",
      "updated_at": "2026-09-15T11:02:45+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJkIjoiMjAyNi0wOS0xNSAxMTowMjo0NSIsImkiOjU1MzB9"
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)[**400**invalid_cursor](https://docs.wolvy.net/api/#error-invalid_cursor)

### Get a session

GET `/v1/sessions/{session_id}`

Scope `sessions:read` · Returns `200`

Returns any session you own, including ones that have not played yet.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `session_id` (required) | string | The session `id`. |

#### Request

```bash
curl "https://api.wolvy.net/v1/sessions/9f2b6c1d3e4a5b60718293a4b5c6d7e8" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "id": "9f2b6c1d3e4a5b60718293a4b5c6d7e8",
  "object": "viewer_session",
  "video_id": "a1b2c3d4e5f60718293a",
  "viewer_id": "user-8813",
  "watch_seconds": 1312,
  "bytes_streamed": 734003200,
  "country": "EG",
  "ip": "203.0.113.24",
  "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36 Edg/140.0",
  "created_at": "2026-09-15T10:40:02+00:00",
  "updated_at": "2026-09-15T11:02:45+00:00"
}
```

Errors you may see: [**404**session_not_found](https://docs.wolvy.net/api/#error-session_not_found)

### Sessions for one video

GET `/v1/videos/{id}/sessions`

Scope `sessions:read` · Returns `200`

Same shape and filters as [List sessions](https://docs.wolvy.net/api/#list-sessions), scoped to one video.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The video id (20 hex characters for API uploads) — the same id that appears in embed URLs. |

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `limit` | integer | Page size, `1`–`100`. Default `50`. |
| `starting_after` | string | The `next_cursor` from the previous page. Omit for the first page. |
| `viewer_id` | string | Only sessions for this verified viewer. |
| `country` | string | Two-letter country code. |
| `created_after` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC — an offset is dropped, not converted. |
| `created_before` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC. |

#### Request

```bash
curl "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/sessions?limit=20" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**404**video_not_found](https://docs.wolvy.net/api/#error-video_not_found)[**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)

_Resource_

## Security events

The audit trail of suspicious and blocked playback. Every event was already handled — these are records, not open incidents. **Severity is the signal to act on.**

| event_type | Confidence | Meaning |
| --- | --- | --- |
| `drm_device_revoked` | High | The device’s DRM client is on the industry revocation list — usually a modified or cracked player. |
| `drm_platform_unverified` | High | Playback was refused because the platform could not be verified. |
| `device_compromised` | High | A mobile device failed integrity checks — likely rooted or modified. |
| `watermark_tamper` | Medium | Someone tried to remove or hide the viewer-ID overlay during playback. |
| `viewer_token_required` | Medium | Enforcement is on and the embed had no valid viewer token. |
| `invalid_app_signature` | Medium | A request claimed to come from the Wolvy app but was not signed correctly. |
| `drm_license_denied` | Medium | A licence request was refused. |
| `drm_key_security_failed` | Low | The device did not meet the security level for high-quality keys; quality was restricted. |
| `invalid_api_key` | Medium | A request to this API used an invalid key (`source: server`). |
| `suspicious_client` | Heuristic | An unusual browser signature. Logged, never blocked — expect false positives. |
| `scope_denied` | Reserved | Accepted as a filter; not currently recorded. |

### List security events

GET `/v1/security-events`

Scope `security:read` · Returns `200`

Newest first, cursor-paginated.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `limit` | integer | Page size, `1`–`100`. Default `50`. |
| `starting_after` | string | The `next_cursor` from the previous page. Omit for the first page. |
| `event_type` | enum | One of the [event types](https://docs.wolvy.net/api/#security-event-types). |
| `severity` | enum | `info`, `warn` or `critical`. |
| `video_id` | string | Only events for this video. |
| `viewer_id` | string | Only events for this verified viewer. |
| `created_after` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC — an offset is dropped, not converted. |
| `created_before` | timestamp | ISO 8601 / RFC 3339, inclusive. Send UTC. |

- `details` carries upstream context and varies by event type — treat it as informational, not a fixed schema.

#### Request

```bash
curl "https://api.wolvy.net/v1/security-events?limit=20&severity=critical" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "id": 20977,
      "object": "security_event",
      "event_type": "drm_device_revoked",
      "severity": "critical",
      "video_id": "a1b2c3d4e5f60718293a",
      "viewer_id": "user-8813",
      "viewer_verified": true,
      "source": "web",
      "ip": "203.0.113.24",
      "country": "EG",
      "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) …",
      "session_id": "9f2b6c1d3e4a5b60718293a4b5c6d7e8",
      "details": null,
      "created_at": "2026-09-15T10:41:13+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJpIjoyMDk3N30"
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)[**400**invalid_cursor](https://docs.wolvy.net/api/#error-invalid_cursor)

_Resource_

## Webhooks

Register URLs that Wolvy posts events to. Endpoints created here and in the dashboard are the same records. How to receive them safely: [Webhooks guide](https://docs.wolvy.net/webhooks/).

### List endpoints

GET `/v1/webhook-endpoints`

Scope `webhooks:manage` · Returns `200`

Every endpoint on the account. Not paginated; never includes secrets.

#### Request

```bash
curl "https://api.wolvy.net/v1/webhook-endpoints" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "id": 3,
      "object": "webhook_endpoint",
      "url": "https://example.com/hooks/wolvy",
      "events": [
        "video.ready",
        "video.failed"
      ],
      "is_active": true,
      "failure_count": 0,
      "created_at": "2026-09-10T08:12:00+00:00",
      "updated_at": "2026-09-10T08:12:00+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Errors you may see: [**403**insufficient_scope](https://docs.wolvy.net/api/#error-insufficient_scope)

### Create an endpoint

POST `/v1/webhook-endpoints`

Scope `webhooks:manage` · Returns `201` Created · Supports `Idempotency-Key`

Returns the signing `secret` **once**. Store it before you do anything else.

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `url` (required) | string | Public https URL (port 443 or 8443) that resolves to public addresses. Re-checked before every delivery. |
| `events` (required) | string[] | At least one of the [event types](https://docs.wolvy.net/webhooks/#events). |

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/webhook-endpoints" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
  -d '{
    "url": "https://example.com/hooks/wolvy",
    "events": [
      "video.ready",
      "video.failed"
    ]
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 201

_201 · application/json_

```json
{
  "id": 3,
  "object": "webhook_endpoint",
  "url": "https://example.com/hooks/wolvy",
  "events": [
    "video.ready",
    "video.failed"
  ],
  "is_active": true,
  "failure_count": 0,
  "created_at": "2026-09-15T12:00:00+00:00",
  "updated_at": "2026-09-15T12:00:00+00:00",
  "secret": "whsec_56b87e856166601d9414d8e3b96f78fe26cbda605a38676a"
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**missing_parameter](https://docs.wolvy.net/api/#error-missing_parameter)[**422**invalid_source_url](https://docs.wolvy.net/api/#error-invalid_source_url)[**422**source_not_allowed](https://docs.wolvy.net/api/#error-source_not_allowed)[**422**source_unreachable](https://docs.wolvy.net/api/#error-source_unreachable)

### Get an endpoint

GET `/v1/webhook-endpoints/{id}`

Scope `webhooks:manage` · Returns `200`

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | integer | Endpoint id. |

#### Request

```bash
curl "https://api.wolvy.net/v1/webhook-endpoints/3" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "id": 3,
  "object": "webhook_endpoint",
  "url": "https://example.com/hooks/wolvy",
  "events": [
    "video.ready",
    "video.failed"
  ],
  "is_active": true,
  "failure_count": 0,
  "created_at": "2026-09-10T08:12:00+00:00",
  "updated_at": "2026-09-10T08:12:00+00:00"
}
```

Errors you may see: [**404**webhook_endpoint_not_found](https://docs.wolvy.net/api/#error-webhook_endpoint_not_found)

### Update an endpoint

PATCH `/v1/webhook-endpoints/{id}`

Scope `webhooks:manage` · Returns `200`

Any of `url`, `events`, `is_active`.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | integer | Endpoint id. |

#### Body (JSON)

| Name | Type | Description |
| --- | --- | --- |
| `url` | string | New public https URL. |
| `events` | string[] | Replaces the whole list. |
| `is_active` | boolean | `true` also resets `failure_count` and resumes pending deliveries. |

#### Request

```bash
curl -X PATCH "https://api.wolvy.net/v1/webhook-endpoints/3" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      "video.ready",
      "video.failed",
      "caption.ready"
    ],
    "is_active": true
  }'
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "id": 3,
  "object": "webhook_endpoint",
  "url": "https://example.com/hooks/wolvy",
  "events": [
    "video.ready",
    "video.failed",
    "caption.ready"
  ],
  "is_active": true,
  "failure_count": 0,
  "created_at": "2026-09-10T08:12:00+00:00",
  "updated_at": "2026-09-15T12:30:00+00:00"
}
```

Errors you may see: [**400**no_fields](https://docs.wolvy.net/api/#error-no_fields)[**404**webhook_endpoint_not_found](https://docs.wolvy.net/api/#error-webhook_endpoint_not_found)[**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**422**invalid_source_url](https://docs.wolvy.net/api/#error-invalid_source_url)[**422**source_not_allowed](https://docs.wolvy.net/api/#error-source_not_allowed)

### Delete an endpoint

DELETE `/v1/webhook-endpoints/{id}`

Scope `webhooks:manage` · Returns `204` No Content

Also deletes its delivery history, including anything still waiting to retry.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | integer | Endpoint id. |

#### Request

```bash
curl -X DELETE "https://api.wolvy.net/v1/webhook-endpoints/3" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 204

No body.

Errors you may see: [**404**webhook_endpoint_not_found](https://docs.wolvy.net/api/#error-webhook_endpoint_not_found)

### Send a test delivery

POST `/v1/webhook-endpoints/{id}/test`

Scope `webhooks:manage` · Returns `202` Accepted · Supports `Idempotency-Key`

Queues a `webhook.test` event so you can check your signature code without waiting for a video to encode. Delivery is asynchronous — check [List deliveries](https://docs.wolvy.net/api/#list-webhook-deliveries).

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` (required) | integer | Endpoint id. |

#### Request

```bash
curl -X POST "https://api.wolvy.net/v1/webhook-endpoints/3/test" \
  -H "Authorization: Bearer $WOLVY_API_KEY" \
  -H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 202

_202 · application/json_

```json
{
  "object": "webhook_delivery",
  "id": 1841,
  "event_type": "webhook.test",
  "endpoint_id": 3,
  "status": "pending"
}
```

Errors you may see: [**404**webhook_endpoint_not_found](https://docs.wolvy.net/api/#error-webhook_endpoint_not_found)

### List deliveries

GET `/v1/webhook-deliveries`

Scope `webhooks:manage` · Returns `200`

The debugging view when your endpoint is not receiving what you expect. Newest first.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `limit` | integer | Page size, `1`–`100`. Default `50`. |
| `starting_after` | string | The `next_cursor` from the previous page. Omit for the first page. |
| `endpoint_id` | integer | Only deliveries to this endpoint. |
| `status` | enum | `pending`, `delivered` or `failed`. |

- Delivered rows are kept 30 days, failed rows 90 days.

#### Request

```bash
curl "https://api.wolvy.net/v1/webhook-deliveries?limit=20&status=failed" \
  -H "Authorization: Bearer $WOLVY_API_KEY"
```

_Also on the page in Node.js, Python, PHP._

#### Response · 200

_200 · application/json_

```json
{
  "object": "list",
  "data": [
    {
      "id": 1841,
      "object": "webhook_delivery",
      "endpoint_id": 3,
      "event_type": "video.ready",
      "attempt": 5,
      "status": "failed",
      "response_code": 500,
      "next_retry_at": null,
      "created_at": "2026-09-15T09:52:43+00:00",
      "delivered_at": null
    }
  ],
  "has_more": true,
  "next_cursor": "eyJpIjoxODM0fQ"
}
```

Errors you may see: [**400**invalid_parameter](https://docs.wolvy.net/api/#error-invalid_parameter)[**400**unknown_parameter](https://docs.wolvy.net/api/#error-unknown_parameter)[**400**invalid_cursor](https://docs.wolvy.net/api/#error-invalid_cursor)

Something wrong or unclear on this page? Email [contact@wolvy.net](mailto:contact@wolvy.net?subject=Docs%3A%20API%20reference) — include the page name.
