# ViewStream — developer guides (full text for LLMs) > ViewStream is a managed video platform built in Israel: live channels, catch-up recording, VOD library, AI subtitles and summaries, clips, a multi-CDN delivery network and a REST API + remote MCP server. Index: https://www.viewstream.co.il/llms.txt · API reference: https://api.viewstream.co.il/docs/ · OpenAPI: https://api.viewstream.co.il/openapi.yaml --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # Getting started with the ViewStream API This page takes you from nothing to a first authenticated call. It is for developers who connect a CMS, a newsroom tool or a backend to ViewStream. ## Base URL | What | URL | |---|---| | Management API (everything on this page) | `https://api.viewstream.co.il/v1` | | Interactive reference (Swagger UI) | `https://api.viewstream.co.il/docs/` | | OpenAPI documents | `https://api.viewstream.co.il/openapi.yaml`, `/openapi.json`; Sites Delivery API: `/openapi/sites-delivery.yaml`, `.json` | The API is JSON over HTTPS. Every request with an unknown `Host` header is refused with `421 misdirected_request` (source: `hostAllowList` in middleware.go). > **The reference page calls production.** "Try it out" on `/docs/` sends real requests with your key. Explore with > a key that has read-only scopes. ## 1. Create an API key in Studio You need a Studio role that holds `keys:manage` — **Engineer (מהנדס)** or higher. 1. In Studio, open **Integrations (אינטגרציות)** in the side menu. 2. In the **API keys (מפתחות API)** section, type a **Key name (שם המפתח)**, for example `CMS production` (at most 120 characters). 3. Tick the **Scopes (הרשאות)** the integration needs. Pick the smallest set; the table below says what each one opens. 4. Click **Create key (יצירת מפתח)**. 5. Copy the key from the box *"Your new key — shown once…"* (*"המפתח החדש שלכם — מוצג פעם אחת…"*) and store it in your secret store. ViewStream keeps only a hash; the key cannot be shown again. Rules enforced by the API (apikeys.go): - A key never gets a scope that you yourself do not hold (`422`, *"you cannot grant a scope you do not hold"*). - A key belongs to exactly one tenant and keeps its scopes for life. To change scopes, create a new key and revoke the old one. - **Revoke (ביטול)** asks you to type the key prefix; every system that uses the key gets `401` immediately. - The same actions are available over the API: `GET /v1/api-keys`, `POST /v1/api-keys {name, scopes[]}`, `DELETE /v1/api-keys/{id}` (scope `keys:manage`). The plaintext key is returned once, in the `key` field of the `201` answer. Key format: `vs_<8-character prefix>_<32 random characters>` (letters and digits). The prefix is what Studio shows in the key list and what the audit log records. Keep keys on servers. Never put a key in a web page, a mobile app or a public repository. ## 2. Send the key as a bearer token ```bash export VS_KEY='vs_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' # placeholder — use your own key curl -s https://api.viewstream.co.il/v1/me -H "Authorization: Bearer $VS_KEY" ``` `GET /v1/me` needs no scope. For a key it answers: ```json { "user": null, "auth": "api_key", "current_tenant": {"id": "…", "slug": "acme", "name": "…", "mode": "platform", "cdn_hostname": "cdn.acme.vustream.net", "role": "api_key"}, "tenants": [ { "…": "the same tenant" } ], "scopes": ["assets:read", "channels:read"] } ``` - `mode` is `platform` (full product) or `cdn` (CDN-only tenant). CDN-only tenants get `403 feature_disabled` on assets, uploads, channels, clips and player configurations (`platformOnly` in middleware.go). - `scopes` is exactly what the key may do. If the header is missing, malformed, unknown or revoked, the API answers `401 invalid_credentials` with `WWW-Authenticate: Bearer realm="viewstream"`. After repeated failures from one IP address (30 in 10 minutes) or for one key prefix (10 in 10 minutes) further attempts get `429` before the key is even checked (middleware.go, `keyFailPerIP`, `keyFailPerPrefix`). ## 3. Scopes A route that needs a scope your key lacks answers `403 insufficient_scope`, and `detail` names the scope (`"this route requires scope assets:write"`). Some routes accept one of two scopes; `detail` then lists both with "or". The table is built from auth/scopes.go (the scope list and the role table) and the router public.go (what each scope opens). "Lowest role" is the lowest Studio role that holds the scope; roles add up (an Editor has everything a Viewer has). | Scope | Lowest role | What it opens (main routes) | |---|---|---| | `assets:read` | Viewer (צופה) | `GET /assets`, `/assets/{id}`, `/trash`, `/jobs`, `/jobs/{id}`, `/jobs/failures`, `/collections`, `/posters/…`, `/subtitles/settings`, `/subtitles/tracks`, `/subtitles/…/cues`, `/summaries/settings`, `/programmes/{id}/summary`, `/assets/{id}/summary`, `/images/…`, `/assets/{id}/ai-video`, `/assets/{id}/packaging`, `/library/imports`, `/player-configs`, `/player-config-assignments`, `/tenant/branding`, `/tenant/defaults` | | `assets:write` | Editor (עורך) | create/patch/re-encode/delete/restore assets; **uploads** (`/uploads…`); posters; collections; library imports; subtitle edits, re-runs; summaries edit/regenerate; image and video upscale; job retry/ignore; with `channels:read` also programme → VOD publishing | | `assets:publish` | Publisher (מפרסם) | the `publish` field of `PATCH /assets/{id}`; publishing catch-up programmes to VOD | | `assets:purge` | Admin (מנהל) | `DELETE /assets/{id}?permanent=true`, `POST /trash/empty`, `PATCH /trash/settings` | | `clips:read` | Viewer | `GET /clips`, `/clips/{id}` | | `clips:write` | Editor | `POST /clips`, `DELETE /clips/{id}` | | `clips:publish` | Publisher | only checked when `POST /clips` carries `"publish": true` (see the note in [recipes.md](recipes.md#create-a-clip)) | | `channels:read` | Viewer | `GET /channels…`, programmes, EPG (`/channels/{id}/epg/…`, `/epg/catalog`, `/epg/destinations`), recording status/timeline/thumbs, catch-up list and search, live-to-VOD rule, boundaries, blackouts, ingest settings and events, packaging, lip-sync, catch-up exclusions (read) | | `channels:write` | Engineer (מהנדס) | create/change/delete channels; ingest settings (`PUT /ingest`, validate, apply, disable, migrate, allowed IPs); recording mode; ad settings and ad breaks; boundaries; markers; EPG source and import; also accepted instead of `epg:write`/`epg:publish` (older keys) | | `channels:operate` | Engineer | live controls: `POST /channels/{id}/ingest/switch`, `/slate`, `/restart`; reveal ingest secrets and rotate/reveal the SRT passphrase; acknowledge alerts | | `uploads` | Editor | **Gates no route today** — `/v1/uploads…` checks `assets:write`. See contradictions | | `prewarm` | Engineer | `POST /prewarm`, `GET /prewarm/{id}`; polling the job with `GET /jobs/{id}` | | `delivery:write` | Engineer | `POST /purge`; distributions (create, patch, delete, rotate secret, sign URL); playback policies and attachments; blackouts (create, delete); partner CDNs and steering; player configurations and preset assignments (write) | | `webhooks:manage` | Engineer | `/webhooks…`, `/inbound-hooks…`, `/event-destinations…`, `/event-inbox` | | `storage:manage` | Engineer | `POST /storage/usage/refresh` only (`GET /storage/keys` and `/storage/usage` need `stats:read`) | | `keys:manage` | Engineer | `/api-keys…`; `/playback/keys` (list, rotate, export) | | `stats:read` | Viewer | `/stats/…` (realtime, overview, timeseries, breakdown, top, traffic, QoE, CDN, completion, programmes, sessions, ads, sites, protection, raw export); reports and report schedules (read); distributions, policies, CDN partners, steering (read); storage usage; security leaks/revocations/settings (read); monitors, alerts and service status (read, as an alternative to `channels:read`) | | `stats:pii` | Admin | viewer-level columns in raw exports, viewer ids in session search, hashed IPs in event export, `DELETE /stats/viewers/{vid}` | | `events:read` | Viewer | `GET /events/stream` (Server-Sent Events) and `GET /notifications` | | `team:manage` | Admin | users, invitations, tenant audit log (`GET /audit`); report schedules (or `notifications:manage`) | | `notifications:manage` | Admin | notification rules; create/change monitors, alert destinations, snooze; acknowledge alerts; report schedules | | `billing:read` | Owner (בעלים) | `GET /billing/usage`, `/billing/statements` | | `tenant:settings` | Owner | `PUT /tenant/branding`, `/tenant/defaults`, `/subtitles/settings`, `/summaries/settings`, `/images/settings`, `/packaging/settings`, `/lipsync/settings`; catch-up exclusion rules and overrides | | `sites:read` | Viewer | `GET /sites…`, `/pages/{id}…`, `/series…`, `/people…` | | `sites:write` | Editor | edit pages (drafts), menus, redirects, shows (`/series`), people; preview links; page check | | `sites:publish` | Publisher | publish pages, menus and themes; cancel a scheduled version | | `sites:admin` | Engineer | create/delete sites, domains, theme, routes | | `playback:sign` | Engineer | `POST /playback/tokens` (tokenised playback URLs for your backend) | | `security:manage` | Engineer | `PUT /security/settings`, `POST /security/revocations`, `POST /security/leaks/{id}/dismiss` | | `epg:write` | Editor | edit the guide: programmes, draft operations, fixes, copy/shift, templates (or `channels:write`) | | `epg:publish` | Publisher | publish/roll back the guide, workflow mode, EPG destinations (or `channels:write`) | | `support:read` | Viewer | `GET /support/requests`, `/support/requests/{id}` (the tenant's support requests and their conversation) | | `support:write` | Viewer | `POST /support/requests` (10 per tenant per hour), `/support/requests/{id}/messages`, `/support/requests/{id}/close` | Every route under `/v1` except login, invitations and `GET /v1/me`, `/v1/tenants` also needs a current tenant; for an API key that is always the key's tenant. ## 4. Your first useful call List the newest ready videos: ```bash curl -s "https://api.viewstream.co.il/v1/assets?status=ready&limit=10" \ -H "Authorization: Bearer $VS_KEY" ``` ```json {"items": [{"id": "0192…", "title": "…", "status": "ready", "published": true, "playback": {"hls": "https://cdn…/master.m3u8", "poster": "…"}, "…": "…"}], "next_cursor": "MDE5Mj…"} ``` Next steps: - [conventions.md](conventions.md) — errors, pagination, rate limits, async jobs, the event stream, time formats. - [recipes.md](recipes.md) — upload a video, catch-up, playback tokens, clips, statistics, monitors, webhooks. - [player-integration.md](player-integration.md) — put the player on a page. --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # API conventions Rules that hold for every route of `https://api.viewstream.co.il/v1`. Each rule links to the code it comes from. ## Requests and responses - **JSON in, JSON out.** Send `Content-Type: application/json`. Responses are `application/json`, or `application/problem+json` for errors. - **Unknown fields are refused.** Request bodies are decoded strictly: a field the route does not know answers `400 validation_error` with `detail: "invalid JSON body: json: unknown field …"`. Bodies are size-limited per route (typically 4 KB – 1 MB) (`decodeJSON` in problem.go). - **Ids are UUIDs** (UUIDv7, so they sort by creation time). Channels and tenants also have a slug (`main`, `acme`) that appears in delivery URLs. - **No caching.** Every API answer carries `Cache-Control: no-store`. The public documents outside `/v1` (EPG exports, player configurations, Sites Delivery API, manifests) set their own cache headers. - **Request id.** Every response carries `X-Request-Id`. Send your own (up to 128 characters) to correlate logs; it is echoed back. Error bodies repeat it as `request_id`. Quote it to support (telemetry). ## Time formats and time zones | Where | Format | |---|---| | Timestamps in responses | RFC 3339 in UTC, e.g. `2026-10-06T08:15:00Z` | | Time query parameters (`from`, `to`, `at` on stats, catch-up, recording thumbs) | RFC 3339 **or** epoch milliseconds (`parseInstant` in thumbs.go, `statsFilter` in stats.go) | | Durations | named with their unit: `duration_ms`, `duration_s`, `ttl_s`, `for_s` | | Catch-up / clip windows in playback URLs | epoch milliseconds (`/m/catchup////…`) | | Public XMLTV export | local guide time, `Asia/Jerusalem` (config `GuideLocation`) | | Sites Delivery API `epg?day=` | a calendar day in `Asia/Jerusalem` | | Webhook `X-VS-Timestamp` | Unix seconds | When you omit `from`/`to` on statistics routes you get the last 24 hours. Statistics ranges may be up to 2 years; raw exports up to 31 days (stats.go). ## Errors (problem+json) Errors follow RFC 9457 (problem.go): ```json { "type": "https://viewstream.co.il/problems/validation_error", "title": "Validation failed", "status": 422, "detail": "invalid api key", "request_id": "0192a4c1-…", "errors": [ {"field": "scopes", "detail": "you cannot grant a scope you do not hold: billing:read"} ] } ``` Branch on `status` and the last path segment of `type`; `title` is fixed per type, `detail` is human-readable and may change. | `type` suffix | Status | Typical cause (example `detail` from the code) | |---|---|---| | `invalid_credentials` | 401 | no or bad key — `"API key is unknown or invalid"`, `"API key is revoked"` | | `insufficient_scope` | 403 | `"this route requires scope assets:write"` | | `forbidden` | 403 | no current tenant (`"no current tenant; call POST /v1/me/tenant first"`) | | `tenant_suspended` | 403 | the tenant is suspended: playback keeps working, changes are blocked (admin_p6.go) | | `feature_disabled` | 403 / 503 | CDN-only tenant (`"assets, uploads, channels, clips and player config are not available to CDN-only tenants"`), or a feature not switched on for this deployment | | `not_found` | 404 | the id does not exist **or belongs to another tenant** (the API never tells the two apart) | | `conflict` | 409 | `"an asset with that external_id exists for this tenant"`, `"only a ready asset can be published (status encoding)"`, another stats export already running | | `lock_window` | 409 | an EPG change reaches into the guide's locked hours | | `misdirected_request` | 421 | the `Host` header is not served here | | `validation_error` | 400 / 422 | 400 = the body is not valid JSON or has unknown fields; 422 = the values are wrong, `errors[]` lists `{field, detail}` | | `no_recording` | 404 | manifests (`/m/…`) only: a start-over / catch-up window with nothing recorded, or an excluded programme | | `rate_limited` | 429 | see below; always with `Retry-After` | | `internal_error` | 500 / 502 | our side; retry later and quote `request_id` | | `not_ready` | 503 | a dependency is down or not configured (`"search is not available"`, `"object storage is not configured"`) | Example — a key without the scope: ```bash curl -s -X POST https://api.viewstream.co.il/v1/clips -H "Authorization: Bearer $VS_KEY" -d '{}' ``` ```json {"type":"https://viewstream.co.il/problems/insufficient_scope","title":"The API key lacks the scope this route needs","status":403,"detail":"this route requires scope clips:write","request_id":"…"} ``` ## Pagination Lists that can grow without bound use **cursor pagination**, newest first: ``` GET /v1/assets?limit=50 → {"items": [...], "next_cursor": "MDE5Mj…"} GET /v1/assets?limit=50&cursor=MDE5Mj… → {"items": [...], "next_cursor": null} ``` - `limit` defaults to 50; the maximum is 200 on assets, clips and jobs (`422` outside 1–200). Each route's maximum is in the API reference. - `next_cursor: null` = last page. Cursors are opaque; do not build or decode them. - Some lists are windows rather than pages: the catch-up list takes `from`/`to` (at most 8 days apart), catch-up search returns at most 50 hits with `truncated: true` when there are more. ## Rate limits | Limit | Value | Source | |---|---|---| | Every authenticated request, per API key (or per Studio session) | 20 requests/s, burst 100 | `ratelimit.New(20, 100, …)` in public.go, ratelimit | | Failed key checks | 30 per client IP / 10 per key prefix in 10 minutes | middleware.go | | `POST /v1/prewarm` | 6 per minute per tenant | `PrewarmPerMinute` | | Statistics raw exports | 1 running at a time, 10 per hour, 3 prepared files waiting | stats_export.go, statsexport/store.go | | `POST /v1/playback/session` (public issuer) | per client IP, plus the policy's own issue rate | protection.go | Over a limit the API answers `429 rate_limited` with `Retry-After` in seconds. Back off exponentially on `429` and `503`. The per-key limiter lives in each API process (not shared between replicas) . ## Idempotency and retries There is **no `Idempotency-Key` header**. What is safe to retry: - `GET`, `PUT` and `DELETE` are idempotent. `DELETE /v1/api-keys/{id}` on an already revoked key answers `404`. - `POST /v1/assets` (and `POST /v1/uploads/{id}/complete`) with an `external_id`: a retry of a create that already succeeded answers `409 conflict` instead of making a second asset. Always send an `external_id` from your CMS. - Webhook deliveries carry `X-VS-Delivery`, stable across retries — de-duplicate on it. - Other `POST`s (clips, prewarm, purge, exports) create a new object each time. ## Optimistic concurrency Only the Sites builder uses it: `PATCH /v1/pages/{id}` needs `If-Match: ` (`428` without it, `409` when stale) (sites.go). ## Asynchronous work: 202 and polling Long work runs as **jobs**. The pattern: 1. The request answers `201` (the object exists and work has started) or `202 Accepted` (work queued). The answer names what to watch — a job id, an object with a `status`, or `{"queued": true}`. 2. Watch it by **webhook** (preferred), by the **event stream**, or by **polling**. | Started by | Answer | Watch | |---|---|---| | `POST /v1/assets`, `POST /v1/uploads/{id}/complete` | `201` asset, `status: probing`, `jobs[]` | `asset.ready` / `asset.failed` webhook, or `GET /v1/assets/{id}` until `status` is `ready` or `failed` | | `POST /v1/assets/{id}/reencode` | `202 {job, version}` | `GET /v1/jobs/{id}` | | `POST /v1/clips` with `precision: "frame"` | `201` clip, `status: finalizing` | `clip.final` webhook or `GET /v1/clips/{id}` (`final` / `failed`) | | `POST /v1/prewarm` | `202 {id, status, run}` | `prewarm.finished` webhook or `GET /v1/prewarm/{id}` | | `POST /v1/purge` | `202 {job_id, keys, prefix}` | `GET /v1/jobs/{id}` | | `POST /v1/stats/exports` | `202` export, `status: queued` | `GET /v1/stats/exports/{id}` until `ready` | | `POST /v1/channels/{id}/programmes/{pid}/publish-vod` | `202` | catch-up list `vod.status` | | `POST /v1/library/imports/{id}/start` | `202` import | `GET /v1/library/imports/{id}` | Asset statuses: `registered → probing → queued → encoding → packaging → ready`, or `failed`; `deleted` = in the trash. Job statuses: `queued`, `dispatched`, `running`, `succeeded`, `failed`, `cancelled` (jobs.go). `GET /v1/jobs/{id}` returns the job with its `events[]`; error texts are redacted of internal addresses. Poll gently: every 5–10 seconds for a single object is plenty, and remember the 20 requests/s budget. ## The event stream (Server-Sent Events) `GET /v1/events/stream` pushes your tenant's events as they happen (sse.go, sse/hub.go). An API key needs `events:read`. ```bash curl -N https://api.viewstream.co.il/v1/events/stream -H "Authorization: Bearer $VS_KEY" ``` ``` : connected id: 01J9… event: asset.status data: {"asset_id":"…","status":"encoding"} : ping ``` - Frames are `id:` / `event:` / `data:` (JSON). A comment `: ping` arrives every 15 s. - **Reconnect with `Last-Event-ID`**: events of the last 5 minutes are replayed. If your id is older, the first frame is `event: resync` — refetch the state you care about. - A connection lasts at most one hour; it ends with `event: reconnect` (`{"reason":"max_lifetime"}`). Reconnect. - The stream carries UI events too (`asset.status`, `job.status`, `job.progress`, `stats.realtime`, …) that are never sent as webhooks. For server-to-server integration prefer [webhooks](recipes.md#webhooks): they are retried for 12 hours; the stream is best-effort. ## Tenants A key belongs to one tenant and always acts in it. Studio users with several tenants switch with `POST /v1/me/tenant` (session only). Objects of another tenant answer `404`, never `403`. A **suspended** tenant is read-only: writes answer `403 tenant_suspended`; playback keeps working. --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # API recipes Worked examples with `curl`. Every example assumes: ```bash export API=https://api.viewstream.co.il/v1 export VS_KEY='vs_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' # placeholder — your key H=(-H "Authorization: Bearer $VS_KEY" -H "Content-Type: application/json") ``` Ids and hostnames in the answers are shortened or invented. Field lists are from the handlers named under each recipe; the full schemas are in the API reference at `https://api.viewstream.co.il/docs/`. - [Upload a video](#upload-a-video) - [Register a video from a URL or your ingest bucket](#register-a-video-from-a-url-or-your-ingest-bucket) - [List catch-up programmes and search them](#list-catch-up-programmes-and-search-them) - [Get a playback URL or a signed token](#get-a-playback-url-or-a-signed-token) - [Create a clip](#create-a-clip) - [Read statistics and export raw data](#read-statistics-and-export-raw-data) - [Manage monitors](#manage-monitors) - [Webhooks](#webhooks) - [Purge and pre-warm the CDN](#purge-and-pre-warm-the-cdn) --- ## Upload a video Scope: `assets:write` (platform tenants only). Source: `startUpload`, `putUploadPart`, `completeUpload` in assets.go. The upload is an S3 multipart upload in **parts of 64 MiB** (67 108 864 bytes), at most 200 GB per file, completed within one hour. **1. Start the upload.** ```bash SIZE=$(stat -c %s news.mp4) curl -s -X POST "$API/uploads" "${H[@]}" \ -d "{\"filename\":\"news.mp4\",\"size_bytes\":$SIZE,\"content_type\":\"video/mp4\"}" ``` ```json {"upload_id": "…", "key": "ingest/…/in/0192…/news.mp4", "part_size": 67108864, "parts": [{"part_number": 1, "url": "…presigned…"}, {"part_number": 2, "url": "…"}], "expires_at": "2026-10-06T09:15:00Z"} ``` **2. Send each part through the API.** The presigned `url`s point at internal storage that is **not reachable from outside** our network; use `PUT /v1/uploads/{upload_id}/parts/{n}?key=` instead. The body must be exactly `Content-Length` bytes, at most `part_size`. URL-encode `upload_id` and `key`. ```bash # UPLOAD_ID and KEY are upload_id and key from step 1 split -b 67108864 -d -a 4 news.mp4 part. n=1; for f in part.*; do curl -s -X PUT "$API/uploads/$UPLOAD_ID/parts/$n?key=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' "$KEY")" \ -H "Authorization: Bearer $VS_KEY" --data-binary @"$f" n=$((n+1)) done # each answer: {"part_number": 1, "etag": "\"9b2c…\""} ``` Parts may be sent in parallel. A failed part answers `409 conflict`; send that part again. **3. Complete the upload and register the asset.** ```bash curl -s -X POST "$API/uploads/$UPLOAD_ID/complete" "${H[@]}" -d '{ "key": "'"$KEY"'", "parts": [{"part_number": 1, "etag": "\"9b2c…\""}, {"part_number": 2, "etag": "\"77aa…\""}], "external_id": "cms-48213", "title": "Evening news 6 Oct", "publish": "manual", "metadata": {"description": "…"} }' ``` The answer is `201` with the asset (`status: "probing"`). Optional fields: | Field | Values | |---|---| | `external_id` | your id, 1–200 characters of letters, digits and `. _ : / -`; unique per tenant (a repeat answers `409`) | | `title` | up to 500 characters | | `ladder` | encoding ladder name; default = the tenant's default ladder | | `publish` | `auto` (default: the tenant's auto-publish setting decides when the asset is ready) or `manual` (stays a draft) | | `metadata` | JSON object up to 16 KB; `metadata.ads` = `{disabled?, cues?: [seconds…]}` (at most 50 cues) | **4. Wait for it to be ready** — the `asset.ready` webhook, or poll `GET /v1/assets/{id}` until `status` is `ready` (or `failed`, with `error`). Then publish a draft: ```bash curl -s -X PATCH "$API/assets/$ASSET_ID" "${H[@]}" -d '{"publish": true}' # needs assets:publish ``` Publishing a not-yet-ready asset answers `409`. ## Register a video from a URL or your ingest bucket Scope: `assets:write`. `POST /v1/assets` with a `source`: ```bash curl -s -X POST "$API/assets" "${H[@]}" -d '{ "source": {"kind": "url", "url": "https://media.example.com/promo.mp4"}, "external_id": "cms-48214", "title": "Promo" }' ``` | `source.kind` | Field | Rule | |---|---|---| | `url` | `url` | http(s); fetched server-side, so private and internal addresses are refused (SSRF guard) | | `s3` | `key` | an object under your tenant's `ingest//in/` | | `upload` | — | refused here; use `/v1/uploads` | To import a whole existing library from your website's sitemap, use `/v1/library/imports` (create → review items → `/start`; see the API reference, tag *library-imports*). ## List catch-up programmes and search them Scope: `channels:read`. Source: livevod.go, catchup_search.go. Find the channel id first: `GET /v1/channels`. **The programme list** — default the last 24 hours, newest first; `from`/`to` at most 8 days apart, clipped to the channel's retention: ```bash curl -s "$API/channels/$CH/catchup?from=2026-10-05T00:00:00Z&to=2026-10-06T00:00:00Z" "${H[@]}" ``` ```json {"from": "…", "to": "…", "retention_from": "…", "excluded_hidden": 3, "items": [{ "id": "…", "title": "חדשות הערב", "start_at": "2026-10-05T17:00:00Z", "end_at": "2026-10-05T18:00:00Z", "duration_s": 3600, "airing": false, "coverage": 1, "status": "recorded", "play_start": "2026-10-05T17:00:42Z", "play_end": "2026-10-05T17:58:10Z", "bounds_status": "detected", "bounds_method": "opener", "adjusted_ms": 42000, "thumbnail": "https://cdn…/…jpg", "vod": null }]} ``` | Field | Meaning | |---|---| | `status` | `not_recorded`, `partial` (some of it recorded), `recorded` (≥ 95 % of an ended programme, or on air with anything recorded), or the VOD state `publishing`, `published`, `failed` | | `coverage` | recorded share, 0–1 | | `play_start`, `play_end` | the playback window after AI programme-boundary detection; play these, not `start_at`/`end_at` | | `bounds_status` | `confirmed` (an editor fixed it), `detected` (AI), `unverified`, `epg` (guide times only) | | `excluded_hidden` | programmes left out by catch-up exclusion rules | | `vod` | `{asset_id, status, trigger, …}` once published to the library | **Search the whole retention window** — title, guide description, AI summary (presenters, topics) and what was said (the Hebrew subtitles: those hits carry `moments` — seconds from the programme's start and the line; `transcript=false` skips them). `q` is 2–100 characters, `limit` 1–50: ```bash curl -s -G "$API/channels/$CH/catchup/search" --data-urlencode 'q=תקציב' --data-urlencode 'limit=20' "${H[@]}" ``` Items are the same as above plus `match: {field, snippet}`; `truncated: true` means there were more than `limit`. Library search is `GET /v1/assets?q=…` (2–100 characters, same matching). **Publish a programme to the library** (needs `assets:write`, plus `assets:publish` when the result is published): ```bash curl -s -X POST "$API/channels/$CH/programmes/$PROGRAMME_ID/publish-vod" "${H[@]}" -d '{"publish": false}' # optional: "start_at"/"end_at" (trimmed bounds, saved on the programme), "collection_id", "replace": true ``` `202`; follow it in the list's `vod.status`. Excluded programmes answer `422`. For many at once: `POST /v1/channels/{id}/programmes/publish-vod`. **Play a programme**: build the catch-up manifest from `play_start`/`play_end` in epoch milliseconds: `https:///m/catchup////master.m3u8?c=`. If the channel has a playback policy that requires tokens, get a signed URL instead (next recipe, target `catchup`). ## Get a playback URL or a signed token ### Unprotected content - **VOD**: a ready asset's `GET /v1/assets/{id}` answer carries `playback {hls, dash?, poster, sprite, thumbs_vtt, download}`. - **Clip**: `GET /v1/clips/{id}` → `playback.hls` (`https:///m/clips//master.m3u8?c=`). - **Live**: `https:///live///master.m3u8` (`cdn_hostname` is in `GET /v1/me`). ### Protected content — tokens from your backend Scope: `playback:sign`. Source: `postPlaybackToken` and `targetPaths` in protection.go; token format in tokens.md. ```bash curl -s -X POST "$API/playback/tokens" "${H[@]}" -d '{ "channel": "main", "ttl_s": 21600, "viewer_ip": "203.0.113.7" }' ``` ```json {"src": "https://cdn.acme.vustream.net/t/eyJ2Ijox…/live/acme/main/master.m3u8", "url": "…same as src…", "token": "eyJ2Ijox…", "sid": "01J9…", "expires_at": "2026-10-06T14:00:00Z", "protected": true, "thumbs": "https://cdn…/t/…/rec/…/main/thumbs/"} ``` Name exactly one target: | Target | Body | |---|---| | live channel | `"channel": ""` | | VOD asset | `"asset": ""` | | clip | `"clip": ""` | | catch-up window | `"catchup": {"channel": "", "start": "", "end": ""}` | | start-over | `"startover": {"channel": "", "programme": ""}` | Options: `ttl_s` 0–604 800 (0 = the policy's default: the live TTL, or the video length plus the policy's extra for on-demand), `viewer_ip` and `viewer_asn` (to bind the token to the viewer's network as the policy demands), `viewer_id`. The token is in the **path** (`/t//…`), so relative segment URLs inherit it. - Hand `src` to the player. Ask for a new one when the player reports `token_expired`. - `sid` identifies the playback session; leak detection and **Revoke** (`POST /v1/security/revocations`, scope `security:manage`) work on it. - To sign without calling the API for every play, an Owner can download the signing key and sign in your backend (reference code in Go, Node and PHP: tokens.md). The hosted ViewStream player does not need your backend: it calls the public issuer `POST /v1/playback/session {tenant, channel|asset|clip|catchup|startover, page_origin}` itself, which checks the policy's referrer, country, ASN, datacenter and rate rules. **Not yet available:** DRM. AES-128 encryption of VOD works; FairPlay/Widevine multi-DRM needs a licence vendor and is not available yet. Live AES-128 is not available with the current packager. ## Create a clip Scope: `clips:write`. Source: `createClip` in live.go. Clips are cut from a channel's **recording** by wall-clock time: ```bash curl -s -X POST "$API/clips" "${H[@]}" -d '{ "channel_id": "'"$CH"'", "start_at": "2026-10-06T07:12:05Z", "end_at": "2026-10-06T07:14:40Z", "title": "Interview — budget", "precision": "frame" }' ``` ```json {"id": "…", "status": "finalizing", "precision": "frame", "renditions": ["1080p", "720p", "…"], "playback": {"hls": "https://cdn…/m/clips/…/master.m3u8?c=acme"}, "…": "…"} ``` | Field | Rule | |---|---| | `channel_id`, `start_at`, `end_at` | required; `end_at` after `start_at`, at most 6 hours | | `precision` | `segment` (default — playable at once, cut at segment boundaries, `status: ready`) or `frame` (frame-accurate; `status: finalizing` → `final` after a re-encode job) | | `renditions` | optional subset of the channel's ladder; default = every rendition that was recorded in the range | | `title` | optional | - `422` with `field: start_at` when nothing is recorded in the range. - Clips from a library asset (`asset_id`) are **not implemented yet** (`422 "asset clips arrive with M4"`). - Segment-precision clips expire with the channel's recording retention (`expires_at`). - `publish: true` only checks for `clips:publish`; it does not store anything today (see known gaps). Events: `clip.ready`, then `clip.final` for frame clips. ## Read statistics and export raw data Scope: `stats:read`. Sources: stats.go, stats_export.go, stats/stats.go. Common query parameters: `from`, `to` (RFC 3339 or epoch ms; default the last 24 h; up to 2 years), and filters `country`, `device`, `pathway`, `channel` (slug or id), `asset`, `clip`, `page_host`, `live=true|false`. ```bash # headline numbers, compared with the previous period curl -s "$API/stats/overview?from=2026-10-05T00:00:00Z&to=2026-10-06T00:00:00Z" "${H[@]}" # plays per hour by country curl -s "$API/stats/timeseries?metric=plays&interval=1h&group_by=country" "${H[@]}" # top 10 assets by watch time curl -s "$API/stats/top?entity=assets&metric=watch_time&limit=10" "${H[@]}" # concurrent viewers now curl -s "$API/stats/realtime" "${H[@]}" ``` Metrics include `plays`, `attempts`, `viewers`, `watch_time`, `rebuffer_ratio`, `error_rate`, `startup_avg`, `ad_impressions`, `concurrent`, and traffic metrics `bytes`, `gbps`, `requests`, `cache_hit_ratio`, `origin_bytes`. `interval` is `1m`, `1h` or `1d` (default `1m` up to 1 day, `1h` up to 90 days, else `1d`; each interval has a maximum range). `/stats/top` takes `entity=assets|clips|channels`, `limit` up to 100. Other routes: `/stats/breakdown`, `/stats/traffic`, `/stats/qoe`, `/stats/cdn`, `/stats/completion`, `/stats/programmes`, `/stats/sessions`, `/stats/ads`, `/stats/sites`. Statistics answer `403 feature_disabled` when the analytics store is not configured on the deployment. ### Raw export Reports: `sessions`, `player_events`, `edge_requests`, `site_events`, `programmes`. Formats: `csv` (UTF-8 with BOM, opens in Excel with Hebrew) or `ndjson`. **Streamed** (small ranges): ```bash curl -s -G "$API/stats/export" "${H[@]}" -d report=sessions -d format=csv \ -d from=2026-10-05T00:00:00Z -d to=2026-10-06T00:00:00Z -o sessions.csv -D headers.txt ``` `X-Export-Total-Rows` and `X-Export-Truncated` arrive with the headers; the trailer `X-Export-Rows` says how many rows were written. If the transfer breaks mid-way the connection is aborted — treat an incomplete transfer as failed. **As a file** (large ranges): ```bash curl -s -X POST "$API/stats/exports" "${H[@]}" -d '{"report": "edge_requests", "format": "ndjson", "from": "2026-09-06T00:00:00Z", "to": "2026-10-06T00:00:00Z"}' # 202 {"id": "…", "status": "queued", …} curl -s "$API/stats/exports/$EXPORT_ID" "${H[@]}" # when "status": "ready": "download_url" (signed, valid 15 minutes), "file_name": "viewstream-…ndjson.gz" ``` Limits (per tenant): one export running at a time (`409`), 10 exports per hour and 3 prepared files waiting (`429`, `Retry-After: 600`), at most 31 days per export, at most 1 000 000 rows, 20 minutes per export. Files stay 7 days. Viewer-level columns (viewer id, hashed IP, page URL, user agent, referrer, query strings) are included only when the key holds `stats:pii`, and such a file can be downloaded only by a caller with `stats:pii`. `"email": true` is for signed-in Studio users only (an API key has no address). Every export is audited. ## Manage monitors View: `stats:read` or `channels:read`. Change: `notifications:manage`. Source: monitors.go, monitors/checks.go. ```bash # the check catalogue (types, units, default thresholds and severities, presets) curl -s "$API/monitors/checks" "${H[@]}" # one-call basic monitor for a channel: feed down, recorder gap, startup p95, error rate curl -s -X POST "$API/monitors/presets/basic" "${H[@]}" -d '{"channel_id": "'"$CH"'"}' # a custom monitor curl -s -X POST "$API/monitors" "${H[@]}" -d '{ "name": "Main — silence", "target_kind": "channel", "target_id": "'"$CH"'", "checks": [{"type": "audio_silence", "threshold": 30, "for_s": 60, "severity": "critical"}], "destinations": [], "inapp": true, "notify_resolve": true, "repeat_min": 30, "quiet_hours": {"from": "01:00", "to": "06:00", "tz": "Asia/Jerusalem", "allow_critical": true} }' # maintenance: mute notifications for 2 hours (alerts are still recorded) curl -s -X POST "$API/monitors/$MON/snooze" "${H[@]}" -d '{"minutes": 120, "reason": "encoder swap"}' curl -s -X DELETE "$API/monitors/$MON/snooze" "${H[@]}" ``` - `target_kind`: `channel`, `asset`, `site` (with `target_host`) or `tenant`. Up to 100 monitors per tenant. - A check: `type`, `threshold`, `for_s` (how long the condition must hold), optional `window_s`, `severity` (`info`, `warning`, `critical`), optional `params`. Read the catalogue for which types fit which target. - Snooze: 1 minute to 7 days (`minutes` or `until`). - Destinations (`/v1/alert-destinations`): `POST {kind: "email", target, name?}` — an address of a tenant member is active at once, any other address must confirm by e-mail first. Telegram chats are linked with `POST /v1/alert-destinations/telegram-link`. Up to 50 destinations. - History: `GET /v1/alerts`, acknowledge with `POST /v1/alerts/{id}/ack`. Webhook events `alert.firing`, `alert.resolved`. `GET /v1/service-status` shows the shared platform layers. - Lip-sync checks cannot be created while lip-sync is switched off on the platform (it is off now). ## Webhooks Scope: `webhooks:manage`. Source: m4.go, delivery/webhook.go; background in webhooks.md. In Studio: **Integrations → Webhooks**. **Create an endpoint**: ```bash curl -s -X POST "$API/webhooks" "${H[@]}" -d '{"url": "https://cms.example.com/vs-hook", "events": ["asset.ready", "clip.ready"]}' ``` The `201` answer includes `secret` (`whsec_…`) **once**; later lists show only `secret_hint`. `events: ["*"]` = all. Subscribable events (the authoritative list is `event_types` in `GET /v1/webhooks`): `asset.ready`, `asset.published`, `asset.failed`, `clip.ready`, `clip.final`, `channel.feed_changed`, `channel.ingest_failover`, `channel.recording_status`, `channel.programme_started`, `prewarm.finished`, `security.leak_suspected`, `security.revoked`, `policy.changed`, `epg.published`, `epg.delivery_failed`, `alert.firing`, `alert.resolved`, `ping`. The URL must be public HTTPS; private and internal addresses are refused at creation and at every delivery. **What you receive**: a `POST` with ```json {"id": "", "type": "asset.ready", "at": "2026-10-06T08:00:00Z", "customer_id": "…", "data": {"…": "…"}} ``` and headers `X-VS-Event`, `X-VS-Delivery` (stable across retries), `X-VS-Timestamp` (Unix seconds), `X-VS-Signature: sha256=.")>`, `User-Agent: ViewStream-Webhooks/1`. **Verify the signature** over the raw body, before parsing it: ```python import hmac, hashlib, time def verify(secret: str, body: bytes, ts: str, sig: str) -> bool: if abs(time.time() - int(ts)) > 300: return False want = "sha256=" + hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest() return hmac.compare_digest(want, sig) ``` ```js // Node (express.raw({type: 'application/json'}) so req.body is a Buffer) const crypto = require('node:crypto'); function verify(secret, body, ts, sig) { if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const want = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.').update(body).digest('hex'); return want.length === sig.length && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig)); } ``` **Answer quickly**: any `2xx` within 10 seconds acknowledges. Redirects are not followed. Otherwise the delivery is retried after 10 s, 1 min, 5 min, 30 min, 2 h and 12 h; after the 7th failed attempt it is `dead`. Deliveries run in parallel, so events can arrive **out of order** — order by `at`, de-duplicate on `X-VS-Delivery`. **Operate**: `POST /v1/webhooks/{id}/test` (a signed `ping`), `GET /v1/webhooks/{id}/deliveries` (status `pending`, `failed`, `delivered`, `dead`), `POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver`, `PATCH` to enable/disable, `DELETE`. **Inbound hooks (CMS → ViewStream)**: `POST /v1/inbound-hooks {kind: "cms_publish", mapping}` gives a URL and an `hksec_…` secret; your CMS signs calls to `POST /v1/hooks/{hook_id}` the same way, and ViewStream publishes the matched ready assets and pre-warms them (webhooks.md). **Event export** — viewing statistics and platform events in batches to your HTTPS endpoint, S3 bucket, Kafka topic or GA4 — is a separate feature under `/v1/event-destinations`: see event-export.md. ## AI articles: write, review, publish, receive Scopes: `assets:write` (ask for an article), `ai:read` (read the queue), `ai:write` (edit, reject), `ai:publish` (approve = publish). Source: aiart.go, worker/article.go. Every AI artefact is a draft until an editor publishes it; only published ones reach the site and webhooks. ```bash # 1. ask for an article draft of an ended programme (about 5–10 minutes) curl -s -X POST "$API/programmes/$PROGRAMME_ID/article" "${H[@]}" # 202 {"job_id": "…", "status": "queued", …} curl -s "$API/programmes/$PROGRAMME_ID/article" "${H[@]}" # {job: {status}, artifact: {…} | null} # 2. the review queue curl -s "$API/ai/artifacts?status=draft&kind=article" "${H[@]}" # fix the headline before approving (each change is stored as a correction) curl -s -X PATCH "$API/ai/artifacts/$ARTIFACT_ID" "${H[@]}" -d '{"body": {"headline": "…", "standfirst": "…"}}' # 3. approve = publish (the site shows it; artifact.published fires once) — or reject with a reason curl -s -X POST "$API/ai/artifacts/$ARTIFACT_ID/approve" "${H[@]}" curl -s -X POST "$API/ai/artifacts/$ARTIFACT_ID/reject" "${H[@]}" -d '{"reason": "wrong figures"}' ``` An article's `body`: `headline`, `standfirst`, `summary`, `chapters[{title, description, paragraphs, start_ms, end_ms}]`, `quotes[{text, role, start_ms, end_ms}]` (verbatim from the subtitles; `role` presenter / guest / report, never a guessed name), `entities`, `tags`. Times are ms from the programme's playback start — a moment URL on the site is `?t=`. **Receive it in your CMS**: subscribe a webhook endpoint to `artifact.published` (see Webhooks below). The payload is `{artifact, subject, url, moment_url, jsonld}` — `jsonld` is a schema.org NewsArticle with the programme's VideoObject and a `Clip` per chapter, ready to inject. A 20-line Next.js receiver that checks the signature: examples/nextjs-webhook-receiver. ## Purge and pre-warm the CDN Scopes: `delivery:write` (purge), `prewarm` (pre-warm). Both work for CDN-only tenants too. Source: m4.go. ```bash # purge: urls, a prefix, or everything of an asset or clip curl -s -X POST "$API/purge" "${H[@]}" -d '{"asset_id": "'"$ASSET_ID"'"}' # 202 {"job_id": "…", "keys": 42, "prefix": "…"} — follow with GET /v1/jobs/{job_id} # pre-warm the edges before a premiere (6 per minute per tenant) curl -s -X POST "$API/prewarm" "${H[@]}" -d '{"asset_id": "'"$ASSET_ID"'"}' # also "clip_id", "channel_id" or "urls": [...]; 202 → GET /v1/prewarm/{id}, webhook prewarm.finished ``` --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # MCP server (AI assistants) ViewStream runs a remote **MCP server** (Model Context Protocol) at ``` https://api.viewstream.co.il/mcp ``` With it an AI assistant — Claude, ChatGPT, Cursor, or any MCP client — can work with your ViewStream account in plain language: *"what is on air on our main channel now?"*, *"find last week's programmes about the European championship and summarise them"*, *"which videos were watched most yesterday?"*, *"cut a clip of the first five minutes of the 20:00 news"*. The assistant calls ViewStream **tools** on your behalf, with an API key you give it. ## How it works - **Transport:** MCP Streamable HTTP, protocol revision `2025-06-18` (`2025-11-25` and `2025-03-26` are also negotiated). The client POSTs JSON-RPC 2.0 messages to `/mcp` and gets one JSON response per request. The server is stateless: it issues no `Mcp-Session-Id` and opens no server-initiated stream (`GET /mcp` answers `405`). - **Authentication:** an API key of your tenant, sent as `Authorization: Bearer ` — the same keys, scopes and rate limit (20 requests/second per key, burst 100) as the REST API. OAuth sign-in is not offered; a Studio login cookie is not accepted on `/mcp`. - **Same rules as the API:** every tool calls the public `/v1` API inside the server *as your key*, so it sees only your tenant's data, only what the key's scopes allow, and is validated, rate-limited and audited exactly like a direct API call. Each tool call is also recorded in the audit log as `mcp.tool_call` (tool name and a summary of the arguments — long texts such as a support message are stored only as their length). - **Output:** Hebrew titles, summaries and subtitles are returned as they are (UTF-8). A tool result is capped at about 60 KB; longer lists are shortened and marked `_truncated` — ask for a smaller `limit` or a shorter range. - **Errors:** a failed tool returns a result with `isError: true` and the API's problem detail (type, status, detail), so the assistant can explain or correct the call. ## Create an API key for the assistant In Studio go to **Integrations → API keys → New key** and give the key only the scopes the assistant needs: | You want the assistant to… | Scopes | |---|---| | Read channels, EPG, catch-up, programmes, service status and alerts | `channels:read` | | Read the library, AI summaries and subtitles | `assets:read` | | Read viewing statistics | `stats:read` | | Cut clips | `clips:write` | | Open support requests | `support:write` | | Regenerate AI summaries | `assets:write` | A read-only assistant needs `channels:read`, `assets:read` and `stats:read`. The assistant only *sees* the tools its key can use (`tools/list` is filtered by scope). Treat the key like a password: anyone who has it can do what its scopes allow. Revoke it in the same screen when you stop using the assistant. ## Tools Read tools (no changes to your data): | Tool | What it does | Scope | |---|---|---| | `list_channels` | Channels with id, slug, title, state, encoder feeds, DVR window, retention, live URL | `channels:read` | | `channel_status` | One channel now: on air / next, feed and recording health, alerts firing on it | `channels:read` | | `search_catchup` | Search recorded programmes (title, guide text, AI summary, presenters, topics — and what was said, with the moments) on one or all channels | `channels:read` | | `get_programme` | One programme: times, guide text, channel, recording status, VOD, playback URLs; with `assets:read` also its summary and subtitle languages | `channels:read` | | `get_epg` | The programme guide of a channel between two times (default: 3 hours ago to 21 hours ahead, at most 7 days) | `channels:read` | | `search_library` | Search or list library videos (title, description, external id, AI summary) | `assets:read` | | `get_asset` | One video: status, publication, duration, renditions, playback, summary, subtitle languages | `assets:read` | | `get_subtitles` | Subtitles of a programme, video or clip as a timestamped transcript (`text`) or WebVTT, in pages | `assets:read` | | `get_summary` | The Hebrew AI summary of a programme or video (summary, presenters, topics, status) | `assets:read` | | `stats_overview` | Audience and quality overview of a period with a comparison period | `stats:read` | | `stats_top` | Top videos, clips or channels; or a channel's programmes by audience (`entity: programmes`) | `stats:read` | | `list_alerts` | Monitoring alerts, newest first | `channels:read` or `stats:read` | | `service_status` | Service health per layer (streaming, delivery, origin, QoE) and the AI services | `channels:read` or `stats:read` | | `search_docs` | Search the Studio manual (English and Hebrew), this guide and the API reference | any key | Write tools (they change data — a good assistant asks you before calling them): | Tool | What it does | Scope | |---|---|---| | `create_clip` | Cut a clip (at most 6 h) from a channel's recording between two times; `segment` precision is ready at once, `frame` is finalised by a background job. Channel clips only. | `clips:write` | | `create_support_request` | Open a support request with Interhost (`contact_email` required; at most 10 per tenant per hour) | `support:write` | | `regenerate_summary` | Make a new AI summary of a programme or video from its Hebrew subtitles (replaces the stored one, including an edit) | `assets:write` | Channels can be given by id or slug (`main`, `news`). Times are RFC 3339 (`2026-10-06T20:00:00+03:00`). **Resources** (documents an assistant can read): `viewstream://docs/{lang}/{topic}` — pages of the Studio manual and of this guide, `lang` = `en` or `he`, e.g. `viewstream://docs/he/catch-up`, `viewstream://docs/en/api-recipes`; `viewstream://openapi` — every API operation with its required scope; `viewstream://openapi/{operationId}` — one operation in full. **Prompts:** `daily_report` (yesterday's viewing report) and `find_programme` (find a programme and summarise it). ## Connect an assistant Replace `` with your API key in each example. ### Claude Code ```bash claude mcp add --transport http viewstream https://api.viewstream.co.il/mcp \ --header "Authorization: Bearer " ``` Then ask, for example: *"Using viewstream, what is on air on all channels right now?"* Check the connection with `claude mcp list`. ### Claude Desktop and claude.ai Custom connectors in Claude Desktop and on claude.ai (**Settings → Connectors → Add custom connector**) sign in with OAuth, which this server does not offer yet. Until it does, connect Claude Desktop through the `mcp-remote` bridge, which adds the header (needs Node.js). In `claude_desktop_config.json`: ```json { "mcpServers": { "viewstream": { "command": "npx", "args": ["-y", "mcp-remote", "https://api.viewstream.co.il/mcp", "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer " } } } } ``` ### Cursor In `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project — do not commit the key): ```json { "mcpServers": { "viewstream": { "url": "https://api.viewstream.co.il/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ### Other clients Any client that supports remote MCP servers over Streamable HTTP with a custom header works: use the URL above and the `Authorization` header. Clients that only support the older HTTP+SSE transport can use `mcp-remote` as above. ## Raw JSON-RPC with curl ```bash KEY= MCP=https://api.viewstream.co.il/mcp H=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") # 1. handshake curl -s "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' curl -s "${H[@]}" -H "MCP-Protocol-Version: 2025-06-18" "$MCP" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' # 2. the tools this key may call curl -s "${H[@]}" -H "MCP-Protocol-Version: 2025-06-18" "$MCP" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' # 3. call a tool curl -s "${H[@]}" -H "MCP-Protocol-Version: 2025-06-18" "$MCP" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", "params":{"name":"search_catchup","arguments":{"q":"אליפות אירופה","limit":5}}}' ``` A tool result looks like this (shortened): ```json {"jsonrpc":"2.0","id":3,"result":{ "content":[{"type":"text","text":"{\"q\":\"אליפות אירופה\",\"items\":[…]}"}], "structuredContent":{"q":"אליפות אירופה","items":[{"id":"0192c8a0-…","title":"חמש","start_at":"2026-10-04T17:00:00Z", "status":"recorded","match":{"field":"summary","snippet":"…להעפיל לאליפות אירופה 2028…"},"channel":{"id":"…","slug":"main"}}], "truncated":false,"channels_searched":1}}} ``` ## Errors | HTTP | When | |---|---| | `401` + `WWW-Authenticate: Bearer` | No key, or the key is malformed, unknown or revoked | | `403` | The request came from a web page whose `Origin` is not allowed (protection against DNS rebinding), or the key has no tenant | | `400` | `MCP-Protocol-Version` names a revision the server does not speak | | `405` | `GET /mcp` (no server-initiated stream) or another method than POST | | `406` / `415` | `Accept` excludes `application/json`, or the body is not `application/json` | | `429` | Rate limit (`Retry-After` says when to retry) | Inside a `200` answer, protocol problems are JSON-RPC errors (`-32601` unknown method, `-32602` unknown tool or bad parameters, `-32002` unknown resource), and tool failures are results with `isError: true` carrying the API's problem type: `insufficient_scope` (the key lacks the tool's scope), `not_found` (no such channel or programme *in your tenant*), `validation_error`, `rate_limited`, `feature_disabled`. ## Limits and notes - Catch-up search covers titles, guide texts and AI summaries; subtitle text itself is not searched. - `create_clip` cuts channel recordings only (no clips from library videos); the clip has no separate draft or publish state. - Times in answers are UTC; the assistant converts them to Israel time when you ask. - The server is for one tenant per key. Operator (Interhost admin) functions are not available through MCP. --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # Player integration How to put the ViewStream player (**Player v2** — Video.js 8 with ViewStream's skins and plugins) on your pages, pick a player configuration, and listen to its events. Sources: the loader player/site/embed.js, the player player/site/lab/b/vs-vjs.js, the hosted pages player/site/_hosted/, configuration documents player-configs.md, and player/README.md. ## Three ways to embed Studio builds all three for you: open a channel, clip or video and use **Embed & share (הטמעה ושיתוף)** — **Share link (קישור לשיתוף)**, **Embed (iframe) (הטמעה (iframe))** and **Embed (player tag) (הטמעה (תגית נגן))**. ### 1. Share link — a hosted watch page ``` https://player.viewstream.co.il/w///[?start=&lang=&config=] ``` `kind` is `live` (id = channel slug), `vod` (id = asset id) or `clip` (id = clip id). The page carries social preview tags, so the link unfurls in WhatsApp, Facebook and others. ### 2. iframe — the simplest embed ```html ``` Query options: `autoplay=1` (starts muted), `start=` (not for live), `lang=he|en|ru|ar`, `config=`. ### 3. Player tag — the player inside your page ```html ``` The player appears where the tag sits (or inside `data-target=""`). The loader fetches everything else (Video.js, skin, language, Chromecast, DRM support) from `player.viewstream.co.il`, once per page, even with several players on the page. Common attributes (all optional except a source): | Attribute | Meaning | |---|---| | `data-src` | an HLS (`.m3u8`), DASH (`.mpd`) or MP4 URL | | `data-tenant` | your tenant slug; otherwise taken from the URL (`/live//…`, `/vod//…`, or `?c=`) | | `data-config` | a named player configuration (see below); default = the configuration resolved for the content | | `data-skin` | `classic`, `cinema`, `neon`, `glass`, `minimal`, `emoji`, `retro`, or any of the 100 library skin ids at `/skins/` | | `data-lang` | `he`, `en`, `ru`, `ar` | | `data-autoplay="1"` + `data-muted="1"` | autoplay (browsers allow it only muted) | | `data-start`, `data-end` | seconds | | `data-aspect` | e.g. `16:9` | | `data-width` | maximum width in pixels | | `data-resume` | `ask` (default), `auto`, `off` — continue where the viewer stopped (stored only in the viewer's browser) | | `data-max-seek-back` | live: seconds the viewer may go behind live (`0` = live only) | | `data-back-to-live="0"` | hide the back-to-live button | | `data-restart="1"`, `data-restart-back` | a restart button on live channels without a guide | | `data-cinema="1"` | cinema-mode button (desktop) | | `data-mode="tile"` | a muted preview tile instead of a full player | | `data-options` | JSON for lists and objects (`sources`, `tracks`, `chapters`, `thumbnails`, `ads`, `drm`, `configDoc`) | The full list is the comment at the top of embed.js (loader version 1.3.0). ### Protected streams When a playback policy requires tokens, the player asks the public issuer (`POST /v1/playback/session`) for a signed URL by itself and re-issues when a token expires. If you sign in your own backend instead, pass the signed `src` from `POST /v1/playback/tokens` as `data-src` ([recipes.md](recipes.md#get-a-playback-url-or-a-signed-token)). `embed.allowed_domains` in the configuration limits which sites may embed. ## Player configurations A configuration is a JSON document edited in Studio → **Players (נגנים)**: skin, theme, language, branding and logo, controls, playback defaults, the live EPG overlay, ads, beacons, tile settings, allowed domains. | URL (public, no key, CORS `*`, cached 60 s, `ETag`) | What | |---|---| | `https://player.viewstream.co.il/player/config/.json` | the tenant default | | `https://player.viewstream.co.il/player/config//.json` | a named configuration | | `https://player.viewstream.co.il/player/config//resolve.json?kind=live\|vod\|clip&id=<…>` | the configuration that applies to that content (by assignment: channel → live default → tenant; asset → section → library default → tenant), plus `resolved_from` and the content's title metadata | The same paths answer on `api.viewstream.co.il`. A change in Studio reaches viewers within about a minute. CDN-only tenants and unknown names answer `404` (the player then uses its built-in defaults). Precedence inside the player: attributes on the page > the configuration document > built-in defaults. Manage configurations over the API: `GET/POST /v1/player-configs`, `GET/PATCH/DELETE /v1/player-configs/{id}` (read `assets:read`, write `delivery:write`), and where they apply: `/v1/player-config-assignments`. All keys and defaults: player-configs.md. ## Events Listen on the player box (the element the player was placed in, or any ancestor — the events bubble): | DOM event | `detail` | When | |---|---|---| | `vs:ready` | `{player, engine}` (`engine` is `b` = Player v2, or `tile`) | the player is created | | `vs:error` | `{error}` | the loader failed (bad JSON in `data-options`, missing target, a file did not load) | | `vs:config` | `{theme, config}` | the resolved configuration is applied — theme your page from it | | `vs:cinema` | `{on}` | cinema mode toggled (your page lays itself out; or style `html.vs-cinema-on`) | ```html ``` `e.detail.player` is a Video.js player: all standard Video.js events (`play`, `pause`, `ended`, `timeupdate`, …) work. ViewStream adds, among others: `vsqualitychange`, `vscaptionchange`, `vsskinchange`, `vsthemechange`, `vscast`, `vsstatschange`, `vsmenuopen`, `vsdeny` (the CDN refused playback — token, geo, blackout…), `vstokenrefresh`, `vsreconnect`, `vsstallskip`, `vspathway` (multi-CDN switch `{from, to, reason}`), `vsdrmunavailable`, and the ad events `adstart`, `adend`, `adskip`, `adclick`, `quartile`. These names come from the code (vs-vjs.js); their payloads are not a frozen contract yet. `window.VSEmbed` holds `{version, players}` for every player the loader created on the page. ## Viewing statistics (beacons) The player reports viewing to `https:///b/v1` (fetch keep-alive, `sendBeacon` on unload) when the configuration's `beacon.enabled` is on (default); heartbeat every `beacon.heartbeat_s` seconds (5–120, default 15) (vs-beacon.js). These beacons feed Studio → Analytics and the `/v1/stats` API. Only a hashed anonymous viewer id is used, and none without consent. ## Sites SDK and web components For tiles, rails and a `` element in your own site: `@viewstream/sites-wc` at `https://player.viewstream.co.il/sdk/sites-wc@0.2.4.js` (demo `/sdk/demo.html`, docs studio/packages/sites-wc/README.md). It reads the [Sites Delivery API](sites-delivery-api.md). ## Status of player features | Feature | Status | |---|---| | HLS / LL-HLS live, DVR, start-over, catch-up, EPG overlay | live | | Client-side ads (VAST/VMAP, in-house engine or IMA), house pre-roll | live | | Server-side ad insertion (SSAI) | built, **off by default** (platform switch) | | AES-128 encrypted VOD | live | | FairPlay / Widevine DRM | **pending** — needs the DRM vendor account (work report §54) | | Chromecast | live (Chromium browsers, not iOS) | --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # Sites Delivery API The Sites Delivery API is the **public, read-only, cacheable** API behind ViewStream Sites. The hosted site renderer uses it; you can use it too, to build your own front end (web, app, smart TV) on the content you manage in Studio → Sites. - Base: `https://api.viewstream.co.il/s/v1/{site}/…` — `{site}` is the site's slug (Studio → **Sites**). - No API key. Only published content is returned (drafts need a preview token). - Its own OpenAPI document: `https://api.viewstream.co.il/openapi/sites-delivery.yaml` (pick **Sites Delivery API** on `/docs/`). - Sources: routes in sites/delivery.go; behaviour described in sites.md; product brief viewstream-sites-spec.md. The management side (editing sites, pages, shows, people, menus, theme) is the normal `/v1` API with the `sites:*` scopes — see [getting-started.md](getting-started.md#3-scopes). ## Routes | Route | Returns | |---|---| | `GET /s/v1/{site}/config` | The site: theme tokens, published menus, dictionary, scripts, consent, accessibility, SEO, the player (`tenant`, `config`, `cdn_hostname`), EPG settings, time zone, AI-crawler mode | | `GET /s/v1/{site}/route?path=/show/evening-news` | What a path is: `{kind: page \| template \| redirect \| not_found, page_id?, template_for?, entity?, redirect?}`. Order: redirects, page paths, the site's route table, then built-ins (`/show/:slug`, `/podcast/:slug`, `/v/:slug`, `/c/:slug`, `/p/:slug`, `/live/:slug`, `/person/:slug`, `/category/:slug`, `/search`, `/epg`) | | `GET /s/v1/{site}/pages/{id}?sections=5[&entity=type:slug][&preview=]` | The page version on air now, the first N sections resolved (`items`, `more`), `next_cursor`, the entity for templates | | `GET /s/v1/{site}/pages/{id}/sections?cursor=&count=` | The next sections of a page | | `GET /s/v1/{site}/entities/{type}/{slug}` | One item (show, video, clip, programme, channel, person …) with `playback {mode, src, tenant, channel?, kind}`, a channel's `now`/`next`, series and people; programmes and videos also carry `article` once an editor published their AI article (`headline`, `standfirst`, `summary`, `chapters`, `quotes` with `start_ms`, `entities`, `tags`, `label`, `reviewed`) — never a draft | | `GET /s/v1/{site}/epg?channel=&day=YYYY-MM-DD` | One day of the guide (day in `Asia/Jerusalem`) with `catchup`, `start_over`, `start_over_src`, `series`; published channels only | | `GET /s/v1/{site}/search?q=&type=&cursor=` | Hebrew-aware search over shows, videos and catch-up programmes | | `GET /s/v1/{site}/suggest?q=` | Up to 8 quick suggestions | | `GET /s/v1/{site}/sitemap/{index\|pages\|shows\|videos\|programmes}.xml` | Sitemaps (video sitemap for videos, clips and catch-up) | | `GET /s/v1/{site}/robots` | The site's robots.txt | | `GET /s/v1/{site}/podcast/{slug}/feed.xml` | A show's podcast RSS feed | | `GET /s/v1/_host?…` | Custom domain → site slug (used by the renderer) | ## Availability rules - A programme offers **catch-up** when it has ended, started inside the channel's retention and after the channel's first recorded minute, and is not excluded by a catch-up exclusion rule. - **Start-over** is offered while the programme is on air, if its start is recorded. - Videos appear when `ready` and published; clips when `ready` or `final`. ## Images Items carry `images` with WebP URLs per preset: `tile_16x9` (640×360), `tile_16x9_320` (320×180, the phone candidate), `tile_1x1`, `tile_2x3`, `poster` (1280×720), `hero_16x9` (1920×1080), `title_art`. Every key is optional — fall back to `tile_16x9`. A suggested `srcset`: `tile_16x9_320 320w, tile_16x9 640w, poster 1280w`. ## Caching | Header | Value | |---|---| | `Cache-Control` | `public, s-maxage=30, stale-while-revalidate=300, stale-if-error=86400` (sitemaps 600 s, robots 3600 s; preview answers `private, no-store`) | | `ETag` | send `If-None-Match` for `304` | | `Cache-Tag` | `site:, page:, entity::…` (used for purging) | | `X-VS-Sites-API` | `1` — the API version | | CORS | allowed for the site's own hostnames and Studio | A page with a scheduled change caps `s-maxage` at the moment of that change, so the switch is on time. ## Errors Errors are `application/problem+json` with `Cache-Control: no-store`. A suspended or unknown site answers `404` (`"no such site"`). When Sites is not enabled on a deployment every route answers `503`. Note: the `type` URIs here are derived from the HTTP status text (`…/problems/not-found`), not the `snake_case` codes of the main API (`…/problems/not_found`) — see known gaps. ## Web components Ready-made tiles and player elements for your own pages: `@viewstream/sites-wc` (`https://player.viewstream.co.il/sdk/sites-wc@0.2.4.js`, demo `/sdk/demo.html`; documentation in studio/packages/sites-wc/README.md). See also [player-integration.md](player-integration.md). --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # Next.js and Vercel `@viewstream/next` connects a Next.js (App Router) site to ViewStream and is ready for Vercel. Source, starter site and full reference: `integrations/nextjs` (README) in the ViewStream repository. | Import | What | |---|---| | `@viewstream/next/client` | ``, `` (9:16 AI clips), `` (direct browser upload) | | `@viewstream/next/server` | `createViewStream()` (API helpers with Next cache tags), `createDelivery()` (Sites Delivery API), Connect | | `@viewstream/next/webhook` | `viewstreamWebhook()` — signed webhook route that revalidates the affected pages | ## Set up on Vercel 1. Create an API key (Studio → **Integrations** → **API keys**) with `assets:read`, `clips:read`, `channels:read`, `ai:read`; add `assets:write` for uploads and `playback:sign` for protected streams. 2. Add the environment variables in Vercel: `VIEWSTREAM_API_KEY`, `VIEWSTREAM_TENANT`, `VIEWSTREAM_WEBHOOK_SECRET` (and optionally `VIEWSTREAM_CHANNEL`, `VIEWSTREAM_LANG`, `VIEWSTREAM_SITE_ORIGIN`). Never expose the key with a `NEXT_PUBLIC_` prefix. Uploads stay off unless you set `VIEWSTREAM_UPLOAD_PASSWORD` (editors sign in on `/upload`); in a real site put them behind your own login — a server action can be called by anyone who can reach the site. 3. Add a webhook (Studio → **Integrations** → **Webhooks**) to `https:///api/viewstream/webhook` with `asset.published`, `clip.final` and `artifact.published`. ## Typical calls ```tsx import { createViewStream } from '@viewstream/next/server'; import { ViewStreamPlayer } from '@viewstream/next/client'; const vs = createViewStream(); const video = await vs.assets.get(id); // tag viewstream:asset: const hits = await vs.catchup.search(channelId, 'ריבית'); // items[].moments[{offset_s, text}] const p = await vs.programmes.get(programmeId); // playback.catch_up const summary = await vs.programmes.summary(programmeId); // summary, verified presenters, topics const articles = await vs.articles.list(); // published AI articles const { src } = await vs.signPlayback({ channel: 'main' }, { viewer_ip }); // protected streams ``` ```ts // app/api/viewstream/webhook/route.ts import { viewstreamWebhook } from '@viewstream/next/webhook'; export const POST = viewstreamWebhook({ secret: process.env.VIEWSTREAM_WEBHOOK_SECRET! }); ``` The webhook route checks `X-VS-Signature` (HMAC-SHA256 over `"."`, 300 s window, constant time) and revalidates the cache tags of the published video, clip or article, so the page updates at once. ## Uploads and Connect - **Uploads** go from the browser straight to ViewStream storage in 64 MB parts with an upload-only token; your server starts and completes them (`startBrowserUpload` / `completeBrowserUpload`). Needs a key created through Connect. - **Connect** lets each customer link their ViewStream account (PKCE, one-time code, scoped revocable key) instead of pasting a key: `pkce()`, `connectUrl()`, `exchangeCode()`, `disconnect()`. --- > Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/ # Lovable and other browser-only apps [Lovable](https://lovable.dev) (and Bolt, v0 in client mode, plain Vite + React) builds apps that run entirely in the browser. Anything in that code is public, so **never put a ViewStream API key in it**. A browser app shows ViewStream video with two public pieces instead: | What | How | Key? | |---|---|---| | The list of videos, shows, catch-up | the [Sites Delivery API](sites-delivery-api.md) (`https://api.viewstream.co.il/s/v1/{site}/…`), public and cached | no | | Playback | the hosted player in an `