> 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=<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/<prefix>/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://<cdn_hostname>/m/catchup/<channel slug>/<start ms>/<end ms>/master.m3u8?c=<tenant>`. 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://<cdn>/m/clips/<id>/master.m3u8?c=<tenant>`).
- **Live**: `https://<cdn_hostname>/live/<tenant>/<channel slug>/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": "<slug>"` |
| VOD asset | `"asset": "<asset id>"` |
| clip | `"clip": "<clip id>"` |
| catch-up window | `"catchup": {"channel": "<slug>", "start": "<ms>", "end": "<ms or live>"}` |
| start-over | `"startover": {"channel": "<slug>", "programme": "<programme id>"}` |

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/<token>/…`), 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": "<event 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=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>`, `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 `<page>?t=<start_ms / 1000>`.

**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
```
