{
  "info": {
    "_postman_id": "a7f3c9e1-2b64-4d18-9c0a-6e5f2d81b304",
    "name": "Wolvy Developer API (v1)",
    "description": "Public REST API for Wolvy customers — https://api.wolvy.net/v1\n\n## Setup\n\n1. Import the companion environment, or set these collection variables:\n   - `base_url` — `https://api.wolvy.net`\n   - `api_key` — your `wv_live_…` key\n2. Send **Account → Get account**. If it returns your account, everything works.\n\nKeys are created in the Wolvy dashboard (Settings → API), not through this API — authenticating here already requires one, so this could not be where the first one comes from.\n\n## Conventions\n\n- Auth is `Authorization: Bearer wv_live_…`, set once at collection level.\n- **Branch on the HTTP status code**, not on a field in the body.\n- Errors return `{\"error\":{\"code\":\"…\",\"message\":\"…\",\"request_id\":\"…\"}}`. Branch on `code` — it is stable. Quote `request_id` to support.\n- **Unknown parameters are rejected** with `400 unknown_parameter`, not ignored. Encryption mode, resolutions and original-retention come from your plan and account settings, so they are deliberately not request fields.\n- Lists are cursor-paginated: pass the response's `next_cursor` as `starting_after`.\n- `POST` requests carry `Idempotency-Key`. This collection sends `{{$guid}}`, a fresh value per send — set it manually to a fixed string to test retry-safety.\n- API keys are secrets. Never put one in a browser or mobile app; this API sends no CORS headers, so it cannot be called from a page.\n\nSeveral requests save ids into collection variables (`video_id`, `folder_id`, `webhook_endpoint_id`) so the folders below can be run in order.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [{ "key": "token", "value": "{{api_key}}", "type": "string" }]
  },
  "variable": [
    { "key": "base_url", "value": "https://api.wolvy.net", "type": "string" },
    { "key": "api_key", "value": "", "type": "string" },
    { "key": "video_id", "value": "", "type": "string" },
    { "key": "folder_id", "value": "", "type": "string" },
    { "key": "session_id", "value": "", "type": "string" },
    { "key": "webhook_endpoint_id", "value": "", "type": "string" },
    { "key": "language_code", "value": "en", "type": "string" },
    { "key": "chapter_index", "value": "0", "type": "string" },
    { "key": "moment_index", "value": "0", "type": "string" }
  ],
  "item": [
    {
      "name": "1. Health",
      "item": [
        {
          "name": "Health check",
          "request": {
            "auth": { "type": "noauth" },
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/health",
              "host": ["{{base_url}}"],
              "path": ["health"]
            },
            "description": "No auth. Touches the database on purpose, so it reports `503` + `\"status\":\"degraded\"` when the database link is down rather than reporting green through the one failure mode this architecture is most exposed to."
          }
        }
      ]
    },
    {
      "name": "2. Account",
      "item": [
        {
          "name": "Get account",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK — the key works', () => pm.response.to.have.status(200));",
                  "const json = pm.response.json();",
                  "console.log('Scopes on this key:', (json.api_key || {}).scopes);"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/account", "host": ["{{base_url}}"], "path": ["v1", "account"] },
            "description": "**Scope:** `account:read`\n\nThe natural first call to confirm a key works. Returns the account, its plan, this key's own metadata and its rate limits.\n\n`plan` is `null` when the account has no active plan — uploads will fail until one is assigned."
          }
        },
        {
          "name": "Get player settings",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/settings/player", "host": ["{{base_url}}"], "path": ["v1", "settings", "player"] },
            "description": "**Scope:** `account:read`\n\nRead-only view of theme, colours, resolutions, domain allowlist, subtitles and watermark config.\n\nCheck `watermark.required` here: when true, playback is refused without a valid viewer token, so every embed must come from **Playback → Create playback token**.\n\nChanging these is a dashboard action — they are account-wide, and a key that could silently repaint every embed on your site is a blast radius this API has not earned."
          }
        }
      ]
    },
    {
      "name": "3. Videos",
      "item": [
        {
          "name": "List videos",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/videos?limit=50",
              "host": ["{{base_url}}"],
              "path": ["v1", "videos"],
              "query": [
                { "key": "limit", "value": "50", "description": "1–100, default 50" },
                { "key": "starting_after", "value": "", "description": "next_cursor from the previous page", "disabled": true },
                { "key": "folder_id", "value": "{{folder_id}}", "description": "A folder id, or the literal 'root' for unfiled videos. Omit to list everything", "disabled": true },
                { "key": "status", "value": "ready", "description": "queued | processing | playable | ready | failed", "disabled": true },
                { "key": "q", "value": "", "description": "Substring match on title and description", "disabled": true },
                { "key": "created_after", "value": "", "description": "ISO 8601", "disabled": true },
                { "key": "created_before", "value": "", "description": "ISO 8601", "disabled": true }
              ]
            },
            "description": "**Scope:** `videos:read`\n\nNewest first, cursor-paginated.\n\nNote this differs from the dashboard's own listing: omitting `folder_id` returns your **whole library**, not just root-level videos. An API client listing a library should not silently miss everything filed in a folder."
          }
        },
        {
          "name": "Create video (URL ingest)",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('202 Accepted', () => pm.response.to.have.status(202));",
                  "const json = pm.response.json();",
                  "if (json.id) {",
                  "    pm.collectionVariables.set('video_id', json.id);",
                  "    console.log('Saved video_id:', json.id, '— poll Get video status until ready.');",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}", "description": "Fresh per send. Set a fixed value to test retry-safety" }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"source_url\": \"https://example.com/master.mp4\",\n  \"title\": \"Onboarding\",\n  \"description\": \"Week one.\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{base_url}}/v1/videos", "host": ["{{base_url}}"], "path": ["v1", "videos"] },
            "description": "**Scope:** `videos:write`\n\nWolvy fetches the URL you supply. Returns `202` immediately with `\"status\":\"queued\"`; the fetch and encode happen in the background. Poll **Get video status** or subscribe to `video.ready`.\n\n`source_url` must be **https**, publicly reachable, and stay up for as long as the fetch takes. It is validated before the request is accepted and again before fetching, including every redirect hop — URLs resolving to private addresses are refused.\n\nOptional: `folder_id`.\n\n**There is no `encryption`, `resolutions` or `keep_original` field.** Sending one returns `400 unknown_parameter`. Multi-DRM is a priced plan capability; the other two are account settings.\n\nCommon errors: `422 no_active_plan`, `422 insufficient_storage`, `422 source_unreachable`, `422 source_not_allowed`, `422 unsupported_source_type`."
          }
        },
        {
          "name": "Get video",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}"] },
            "description": "**Scope:** `videos:read`\n\nThe full video object. `id` is the `wolvy_id` — the same id in your embed URLs.\n\n`protection` is read-only (`drm` or `clearkey`) and follows your plan, not the video.\n\n`poster_url`, `duration` and `size_bytes` stay null/0 until the video reaches `ready`. `embed_url` is present from the moment it is created."
          }
        },
        {
          "name": "Get video status",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/status", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "status"] },
            "description": "**Scope:** `videos:read`\n\nMinimal payload — **this is the endpoint to poll** while encoding runs, not the full video object.\n\n`queued` → `processing` → `playable` (first resolution ready, watchable now) → `ready`."
          }
        },
        {
          "name": "Update video",
          "request": {
            "method": "PATCH",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"title\": \"Onboarding (revised)\",\n  \"description\": \"Updated for 2026.\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}"] },
            "description": "**Scope:** `videos:write`\n\nAny of `title`, `description`, `folder_id` (`null` moves it to root). At least one required, or `400 no_fields`."
          }
        },
        {
          "name": "Delete video",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}"] },
            "description": "**Scope:** `videos:write` · Returns `204`\n\n**Irreversible.** Removes the media files, encryption keys and analytics.\n\nViewer session history and security events are deliberately kept — they outlive the video they describe, so your audit trail stays intact.\n\n`503` means the media server could not be reached; retry."
          }
        }
      ]
    },
    {
      "name": "4. Folders",
      "description": "Folders are a **flat set**, not a tree, and **cannot be renamed** — the platform supports neither.",
      "item": [
        {
          "name": "List folders",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/folders?limit=50",
              "host": ["{{base_url}}"],
              "path": ["v1", "folders"],
              "query": [
                { "key": "limit", "value": "50" },
                { "key": "starting_after", "value": "", "disabled": true }
              ]
            },
            "description": "**Scope:** `videos:read`"
          }
        },
        {
          "name": "Create folder",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const json = pm.response.json();",
                  "if (json.id) { pm.collectionVariables.set('folder_id', String(json.id)); }"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}" }
            ],
            "body": { "mode": "raw", "raw": "{\n  \"name\": \"Onboarding\"\n}", "options": { "raw": { "language": "json" } } },
            "url": { "raw": "{{base_url}}/v1/folders", "host": ["{{base_url}}"], "path": ["v1", "folders"] },
            "description": "**Scope:** `videos:write` · Returns `201`"
          }
        },
        {
          "name": "Get folder",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/folders/{{folder_id}}", "host": ["{{base_url}}"], "path": ["v1", "folders", "{{folder_id}}"] },
            "description": "**Scope:** `videos:read`"
          }
        },
        {
          "name": "Delete folder",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/folders/{{folder_id}}", "host": ["{{base_url}}"], "path": ["v1", "folders", "{{folder_id}}"] },
            "description": "**Scope:** `videos:write` · Returns `204`\n\n**Videos in the folder are not deleted** — they move to root. Destroying a library because someone tidied a folder would be a far worse surprise than an unfiled video."
          }
        }
      ]
    },
    {
      "name": "5. Captions",
      "description": "WebVTT. `language_code` is the identifier — re-posting the same one replaces it.",
      "item": [
        {
          "name": "List captions",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/captions", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "captions"] },
            "description": "**Scope:** `videos:read`"
          }
        },
        {
          "name": "Add caption (inline content)",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}" }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"language_code\": \"en\",\n  \"label\": \"English\",\n  \"content\": \"WEBVTT\\n\\n00:00:00.000 --> 00:00:02.000\\nHello and welcome.\\n\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/captions", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "captions"] },
            "description": "**Scope:** `videos:write` · Returns `201`\n\nSupply **exactly one** of `content` (the VTT inline) or `source_url` (https, ≤5 MB). Sending both or neither is a `400`.\n\nThe content must begin with `WEBVTT` or you get `422 invalid_caption_file`."
          }
        },
        {
          "name": "Add caption (from URL)",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}" }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"language_code\": \"pt-BR\",\n  \"label\": \"Português (Brasil)\",\n  \"source_url\": \"https://example.com/captions/pt-BR.vtt\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/captions", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "captions"] },
            "description": "**Scope:** `videos:write`\n\nFetched through the same SSRF guard as video ingest — https only, public addresses only."
          }
        },
        {
          "name": "Delete caption",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/captions/{{language_code}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "captions", "{{language_code}}"] },
            "description": "**Scope:** `videos:write` · Returns `204`"
          }
        }
      ]
    },
    {
      "name": "6. Chapters",
      "description": "Segments on the scrubber. **May not overlap.** Times are whole seconds.\n\nAddressed by **index** — position in the list, which is kept sorted by `start`. That makes an index a weak handle: adding or removing a chapter renumbers the ones after it. Re-read the list after any change rather than caching indexes.",
      "item": [
        {
          "name": "List chapters",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/chapters", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "chapters"] },
            "description": "**Scope:** `videos:read`"
          }
        },
        {
          "name": "Add chapter",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}" }
            ],
            "body": { "mode": "raw", "raw": "{\n  \"title\": \"Introduction\",\n  \"start\": 0,\n  \"end\": 45\n}", "options": { "raw": { "language": "json" } } },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/chapters", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "chapters"] },
            "description": "**Scope:** `videos:write` · Returns `201` with the full sorted list\n\n`409 chapter_overlap` names the chapter it collides with."
          }
        },
        {
          "name": "Update chapter",
          "request": {
            "method": "PATCH",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": { "mode": "raw", "raw": "{\n  \"title\": \"Introduction & setup\"\n}", "options": { "raw": { "language": "json" } } },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/chapters/{{chapter_index}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "chapters", "{{chapter_index}}"] },
            "description": "**Scope:** `videos:write`\n\nAny of `title`, `start`, `end`."
          }
        },
        {
          "name": "Delete chapter",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/chapters/{{chapter_index}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "chapters", "{{chapter_index}}"] },
            "description": "**Scope:** `videos:write` · Returns `204`"
          }
        }
      ]
    },
    {
      "name": "7. Moments",
      "description": "Single-point markers — \"highlights\" in the dashboard. **Timestamps are unique** within a video. Indexed like chapters, sorted by `timestamp`.",
      "item": [
        {
          "name": "List moments",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/moments", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "moments"] },
            "description": "**Scope:** `videos:read`"
          }
        },
        {
          "name": "Add moment",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}" }
            ],
            "body": { "mode": "raw", "raw": "{\n  \"label\": \"Live demo\",\n  \"timestamp\": 120\n}", "options": { "raw": { "language": "json" } } },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/moments", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "moments"] },
            "description": "**Scope:** `videos:write` · Returns `201`\n\n`409 moment_exists` if something is already at that timestamp."
          }
        },
        {
          "name": "Update moment",
          "request": {
            "method": "PATCH",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": { "mode": "raw", "raw": "{\n  \"label\": \"Live demo (updated)\"\n}", "options": { "raw": { "language": "json" } } },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/moments/{{moment_index}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "moments", "{{moment_index}}"] },
            "description": "**Scope:** `videos:write`"
          }
        },
        {
          "name": "Delete moment",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/moments/{{moment_index}}", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "moments", "{{moment_index}}"] },
            "description": "**Scope:** `videos:write` · Returns `204`"
          }
        }
      ]
    },
    {
      "name": "8. Playback",
      "item": [
        {
          "name": "Create playback token",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}" }
            ],
            "body": { "mode": "raw", "raw": "{\n  \"viewer_id\": \"user-8813\",\n  \"ttl_seconds\": 7200\n}", "options": { "raw": { "language": "json" } } },
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/playback-tokens", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "playback-tokens"] },
            "description": "**Scope:** `playback:sign` · Returns `201`\n\nMints a signed viewer token (`vt`) identifying one viewer to the player. This is what makes the dynamic watermark trustworthy: the id is signed with your account's secret, so a viewer cannot edit the URL and watch as someone else.\n\nCall this **from your backend**, per viewer, per session, and put the returned `embed_url` in your page. Never expose the API key that mints it.\n\n`viewer_id` is yours — a user id, an email hash, anything you can trace back. `ttl_seconds` is 60–86400, default 21600.\n\nIf your account has watermark enforcement on, playback is refused without a valid token, so every embed must come from here. Check `watermark.required` in **Account → Get player settings**."
          }
        },
        {
          "name": "Get embed URL",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/embed", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "embed"] },
            "description": "**Scope:** `videos:read`\n\nThe unsigned embed URL and a copy-paste iframe snippet.\n\nThe embed URL enforces your domain allowlist by `Referer`, so it returns `403` when opened outside an approved page — including when pasted straight into a browser address bar. That is the allowlist working, not a broken link."
          }
        }
      ]
    },
    {
      "name": "9. Analytics",
      "item": [
        {
          "name": "Overview",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/analytics/overview",
              "host": ["{{base_url}}"],
              "path": ["v1", "analytics", "overview"],
              "query": [
                { "key": "from", "value": "", "description": "YYYY-MM-DD, default 30 days ago", "disabled": true },
                { "key": "to", "value": "", "description": "YYYY-MM-DD, default today", "disabled": true }
              ]
            },
            "description": "**Scope:** `analytics:read`\n\n`watch_time` is seconds. `storage_bytes` is the **peak** over the range, not a sum — storage is a level, not a flow, and summing daily snapshots would report a library many times its real size. `bandwidth_bytes` *is* summed."
          }
        },
        {
          "name": "Daily",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/analytics/daily",
              "host": ["{{base_url}}"],
              "path": ["v1", "analytics", "daily"],
              "query": [
                { "key": "from", "value": "", "disabled": true },
                { "key": "to", "value": "", "disabled": true }
              ]
            },
            "description": "**Scope:** `analytics:read` · Per-day rows, oldest first."
          }
        },
        {
          "name": "By country",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/analytics/countries",
              "host": ["{{base_url}}"],
              "path": ["v1", "analytics", "countries"],
              "query": [
                { "key": "from", "value": "", "disabled": true },
                { "key": "to", "value": "", "disabled": true },
                { "key": "video_id", "value": "{{video_id}}", "description": "Scope to one video", "disabled": true }
              ]
            },
            "description": "**Scope:** `analytics:read` · Sorted by views descending."
          }
        },
        {
          "name": "For one video",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/videos/{{video_id}}/analytics", "host": ["{{base_url}}"], "path": ["v1", "videos", "{{video_id}}", "analytics"] },
            "description": "**Scope:** `analytics:read`\n\n`views` and `sessions` count different things — `views` is the aggregate counter, `sessions` is distinct tracked playback sessions. Expect them to differ."
          }
        }
      ]
    },
    {
      "name": "10. Usage & plan",
      "description": "Every storage and bandwidth value is **bytes** (1 GB = 1,073,741,824), with a `_bytes` suffix so there is nothing to guess.",
      "item": [
        {
          "name": "Get usage",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/usage", "host": ["{{base_url}}"], "path": ["v1", "usage"] },
            "description": "**Scope:** `usage:read`\n\n`danger_zone` is set by the platform when an account is close to exhausting its allowance — worth alerting on before uploads start failing.\n\n`422 no_active_plan` when the account has no plan."
          }
        },
        {
          "name": "Usage history",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/usage/history",
              "host": ["{{base_url}}"],
              "path": ["v1", "usage", "history"],
              "query": [
                { "key": "from", "value": "", "disabled": true },
                { "key": "to", "value": "", "disabled": true }
              ]
            },
            "description": "**Scope:** `usage:read` · Daily rows from the usage ledger."
          }
        },
        {
          "name": "Get plan",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/plan", "host": ["{{base_url}}"], "path": ["v1", "plan"] },
            "description": "**Scope:** `usage:read`\n\n`protection` here is why creating a video has no encryption parameter. Read-only — changing it means changing plan, in the dashboard."
          }
        }
      ]
    },
    {
      "name": "11. Viewer sessions",
      "description": "Read-only. One row per tracked playback session.\n\n**Privacy:** these records contain your viewers' IP addresses and user agents. You are the data controller for them. If you would rather not receive addresses, ask support to enable IP redaction and the last octet is zeroed.\n\nSessions survive deletion of the video they describe.",
      "item": [
        {
          "name": "List sessions",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const json = pm.response.json();",
                  "if (json.data && json.data.length) { pm.collectionVariables.set('session_id', json.data[0].id); }"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/sessions?limit=50",
              "host": ["{{base_url}}"],
              "path": ["v1", "sessions"],
              "query": [
                { "key": "limit", "value": "50" },
                { "key": "starting_after", "value": "", "disabled": true },
                { "key": "video_id", "value": "{{video_id}}", "disabled": true },
                { "key": "viewer_id", "value": "", "disabled": true },
                { "key": "country", "value": "", "description": "Two-letter code", "disabled": true },
                { "key": "created_after", "value": "", "disabled": true },
                { "key": "created_before", "value": "", "disabled": true }
              ]
            },
            "description": "**Scope:** `sessions:read`\n\n`viewer_id` is populated only when the session started with a signed viewer token — and when present it is **server-verified**, not something the viewer could have set."
          }
        },
        {
          "name": "Get session",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/sessions/{{session_id}}", "host": ["{{base_url}}"], "path": ["v1", "sessions", "{{session_id}}"] },
            "description": "**Scope:** `sessions:read`"
          }
        },
        {
          "name": "Sessions for one video",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/videos/{{video_id}}/sessions?limit=50",
              "host": ["{{base_url}}"],
              "path": ["v1", "videos", "{{video_id}}", "sessions"],
              "query": [
                { "key": "limit", "value": "50" },
                { "key": "starting_after", "value": "", "disabled": true },
                { "key": "viewer_id", "value": "", "disabled": true },
                { "key": "country", "value": "", "disabled": true },
                { "key": "created_after", "value": "", "disabled": true },
                { "key": "created_before", "value": "", "disabled": true }
              ]
            },
            "description": "**Scope:** `sessions:read`"
          }
        }
      ]
    },
    {
      "name": "12. Security events",
      "item": [
        {
          "name": "List security events",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/security-events?limit=50",
              "host": ["{{base_url}}"],
              "path": ["v1", "security-events"],
              "query": [
                { "key": "limit", "value": "50" },
                { "key": "starting_after", "value": "", "disabled": true },
                { "key": "event_type", "value": "drm_device_revoked", "disabled": true },
                { "key": "severity", "value": "critical", "description": "info | warn | critical", "disabled": true },
                { "key": "video_id", "value": "{{video_id}}", "disabled": true },
                { "key": "viewer_id", "value": "", "disabled": true },
                { "key": "created_after", "value": "", "disabled": true },
                { "key": "created_before", "value": "", "disabled": true }
              ]
            },
            "description": "**Scope:** `security:read`\n\nThe audit trail of illegal actions against your content.\n\n**Severity is the signal to act on.** `drm_device_revoked` and `drm_platform_unverified` come straight from the DRM provider's own error codes and are high confidence — a cracked or blacklisted client. `suspicious_client` is a user-agent heuristic that is logged but never blocked on, and **will** produce false positives. Alerting on `critical` is useful; alerting on everything will bury you."
          }
        }
      ]
    },
    {
      "name": "13. Webhooks",
      "description": "Push instead of poll. Endpoints are also manageable from the dashboard (Settings → Webhooks) — both write the same records.\n\n**Verifying a delivery:** every request carries `Wolvy-Signature: t=…,v1=…`, where `v1` is `HMAC-SHA256(secret, \"{t}.{raw_body}\")`. Verify against the **raw** body before any JSON parsing — re-serialising changes the bytes and the signature will never match. Reject anything older than 5 minutes.\n\nRespond **2xx quickly**; anything else retries after 1m, 5m, 30m, 2h, 6h. After 20 consecutive failures the endpoint is auto-disabled. Deliveries can arrive more than once — make your handler idempotent on the event `id`.",
      "item": [
        {
          "name": "List webhook endpoints",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/webhook-endpoints", "host": ["{{base_url}}"], "path": ["v1", "webhook-endpoints"] },
            "description": "**Scope:** `webhooks:manage`"
          }
        },
        {
          "name": "Create webhook endpoint",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const json = pm.response.json();",
                  "if (json.id) { pm.collectionVariables.set('webhook_endpoint_id', String(json.id)); }",
                  "if (json.secret) { console.log('Signing secret:', json.secret); }"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "Idempotency-Key", "value": "{{$guid}}" }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://example.com/hooks/wolvy\",\n  \"events\": [\n    \"video.ready\",\n    \"video.failed\"\n  ]\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{base_url}}/v1/webhook-endpoints", "host": ["{{base_url}}"], "path": ["v1", "webhook-endpoints"] },
            "description": "**Scope:** `webhooks:manage` · Returns `201` **with `secret`**\n\nYour URL must be https and publicly routable — internal addresses are refused, and re-checked at delivery time in case the DNS is repointed afterwards.\n\nValid events: `video.created`, `video.ready`, `video.failed`, `video.deleted`, `caption.ready`, `security.event`, `payment.paid`, `usage.threshold_reached`.\n\nNote `video.deleted` only fires for deletions made through this API — dashboard deletions do not emit it."
          }
        },
        {
          "name": "Get webhook endpoint",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/webhook-endpoints/{{webhook_endpoint_id}}", "host": ["{{base_url}}"], "path": ["v1", "webhook-endpoints", "{{webhook_endpoint_id}}"] },
            "description": "**Scope:** `webhooks:manage`"
          }
        },
        {
          "name": "Update webhook endpoint",
          "request": {
            "method": "PATCH",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"events\": [\n    \"video.ready\",\n    \"video.failed\",\n    \"caption.ready\"\n  ],\n  \"is_active\": true\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{base_url}}/v1/webhook-endpoints/{{webhook_endpoint_id}}", "host": ["{{base_url}}"], "path": ["v1", "webhook-endpoints", "{{webhook_endpoint_id}}"] },
            "description": "**Scope:** `webhooks:manage`\n\nAny of `url`, `events`, `is_active`. Setting `is_active` true also clears the consecutive-failure count — otherwise an endpoint re-enabled after being auto-disabled would trip the same limit immediately."
          }
        },
        {
          "name": "Send test delivery",
          "request": {
            "method": "POST",
            "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }],
            "url": { "raw": "{{base_url}}/v1/webhook-endpoints/{{webhook_endpoint_id}}/test", "host": ["{{base_url}}"], "path": ["v1", "webhook-endpoints", "{{webhook_endpoint_id}}", "test"] },
            "description": "**Scope:** `webhooks:manage` · Returns `202`\n\nQueues a `webhook.test` event so you can verify your signature check without waiting for a video to encode. Delivery is asynchronous — poll **List deliveries** for the outcome."
          }
        },
        {
          "name": "List deliveries",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/webhook-deliveries?limit=50",
              "host": ["{{base_url}}"],
              "path": ["v1", "webhook-deliveries"],
              "query": [
                { "key": "limit", "value": "50" },
                { "key": "starting_after", "value": "", "disabled": true },
                { "key": "endpoint_id", "value": "{{webhook_endpoint_id}}", "disabled": true },
                { "key": "status", "value": "failed", "description": "pending | delivered | failed", "disabled": true }
              ]
            },
            "description": "**Scope:** `webhooks:manage`\n\nThe debugging view when your endpoint is not receiving what you expect."
          }
        },
        {
          "name": "Delete webhook endpoint",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": { "raw": "{{base_url}}/v1/webhook-endpoints/{{webhook_endpoint_id}}", "host": ["{{base_url}}"], "path": ["v1", "webhook-endpoints", "{{webhook_endpoint_id}}"] },
            "description": "**Scope:** `webhooks:manage` · Returns `204`\n\nAlso deletes the delivery history, including anything still queued for retry."
          }
        }
      ]
    }
  ]
}
